Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c2c9cd5 | ||
|
|
79fb675af5 | ||
|
|
880a8fb57c | ||
|
|
a9404f01e2 | ||
|
|
f17f355366 | ||
|
|
1a82894b6a | ||
|
|
6181760045 | ||
|
|
80bb27515c | ||
|
|
af7b56b14b | ||
|
|
a80895b75f | ||
|
|
04f94ad665 | ||
|
|
fd1ec32dd6 | ||
|
|
9d07793a57 | ||
|
|
d26f6ecfd3 | ||
|
|
fef233344b | ||
|
|
783f930380 | ||
|
|
888f323b23 | ||
|
|
689d55bcf9 | ||
|
|
eba3a1b562 | ||
|
|
b764fcb33c | ||
|
|
78797d8eca | ||
|
|
b8f6ca586d | ||
|
|
767fca6a79 | ||
|
|
cfd3fb15cc | ||
|
|
1902949f69 | ||
|
|
a415bcdb88 | ||
|
|
8853ea2dfa | ||
|
|
439241dd83 | ||
|
|
147edd042f | ||
|
|
e58dd86d5b | ||
|
|
aacdca6930 | ||
|
|
3f1cee8006 | ||
|
|
99c26a393a | ||
|
|
e051ab2e9b | ||
|
|
6a428bd468 | ||
|
|
ce445d0fb8 | ||
|
|
8444ded971 | ||
|
|
f4e87b0c02 | ||
|
|
1f66307bba | ||
|
|
a081966342 | ||
|
|
cded4238a9 | ||
|
|
3c3d273ee3 |
@@ -1,4 +1,5 @@
|
|||||||
**
|
**
|
||||||
|
!Dockerfile
|
||||||
!requirements.txt
|
!requirements.txt
|
||||||
!am.py
|
!am.py
|
||||||
!rates_extra.py
|
!rates_extra.py
|
||||||
|
|||||||
@@ -3,3 +3,24 @@
|
|||||||
# The process environment takes precedence, including an explicitly empty value.
|
# The process environment takes precedence, including an explicitly empty value.
|
||||||
# Keep real credentials in .env; never put them in this example or source files.
|
# Keep real credentials in .env; never put them in this example or source files.
|
||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
|
# Optional fixed tenant scope for the internal order test dataset. Empty = all imported test rows.
|
||||||
|
ORDER_DATA_TENANT_ID=
|
||||||
|
# Default front-end customer account: admin / 123456. It grants no staff access.
|
||||||
|
CUSTOMER_DEMO_ENABLED=true
|
||||||
|
CUSTOMER_DEMO_USERNAME=admin
|
||||||
|
# Bind one explicit tenant, or leave empty to use the current local dataset grants.
|
||||||
|
CUSTOMER_DEMO_TENANT_ID=
|
||||||
|
# On initial association, grant the default account the tenants currently in the local table.
|
||||||
|
CUSTOMER_DEMO_LOCAL_ORDERS=true
|
||||||
|
# Optional password hash used only when creating the default account; not plaintext.
|
||||||
|
# CUSTOMER_DEMO_PASSWORD_HASH=
|
||||||
|
# Short-recording ASR is independent of DEEPSEEK_API_KEY. No credentials = voice input unavailable.
|
||||||
|
ASR_PROVIDER=baidu
|
||||||
|
BAIDU_ASR_API_KEY=
|
||||||
|
BAIDU_ASR_SECRET_KEY=
|
||||||
|
# Optional absolute ffmpeg executable path. Empty = find ffmpeg on PATH. Docker includes it.
|
||||||
|
FFMPEG_BIN=
|
||||||
|
# Optional alternative: ASR_PROVIDER=dashscope, with a separate Alibaba Cloud Model Studio key.
|
||||||
|
ASR_API_KEY=
|
||||||
|
ASR_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
|
||||||
|
ASR_MODEL=qwen3-asr-flash
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
|||||||
| Area | Files and search anchors |
|
| Area | Files and search anchors |
|
||||||
|---|---|
|
|---|---|
|
||||||
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
| AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` |
|
||||||
|
| Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` |
|
||||||
|
| Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support |
|
||||||
|
| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout |
|
||||||
|
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
|
||||||
|
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
| 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` |
|
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
|
||||||
|
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
|
||||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||||
|
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.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` |
|
| 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/` |
|
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
|
||||||
@@ -51,7 +58,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
|||||||
|
|
||||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
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.
|
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
@@ -59,4 +66,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
|
|||||||
|
|
||||||
## Security & Configuration Tips
|
## 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.
|
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads root `.env`; environment variables override it. Restart after changes. `create_app` initializes storage unless `INITIALIZE_RUNTIME=False`; order and staff tables start empty. The requested default customer account is separate from staff and stores only a password hash. Default HOST is `0.0.0.0`; Linux deployment binds loopback. Staff authentication relies on Nginx: protect staff and unknown routes by default and expose only the owner-checked customer allowlist. Never expose the upstream port publicly. Guest keys authorize public chat history, never customer orders. Order facts and sensitive conversation history require the server-validated customer and tenant; never accept tenant claims from client input. Do not assign unowned legacy records to a new visitor.
|
||||||
|
|||||||
+9
-1
@@ -6,6 +6,12 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org
|
||||||
|
RUN case "$DEBIAN_MIRROR" in https://deb.debian.org|https://mirrors.tuna.tsinghua.edu.cn) ;; *) exit 2;; esac \
|
||||||
|
&& sed -i "s|http://deb.debian.org|$DEBIAN_MIRROR|g" /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 update \
|
||||||
|
&& apt-get -o Acquire::Retries=2 -o Acquire::https::Timeout=30 install -y --no-install-recommends ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN python -m pip install --no-cache-dir -r requirements.txt \
|
RUN python -m pip install --no-cache-dir -r requirements.txt \
|
||||||
&& groupadd --gid 10001 baiyue \
|
&& groupadd --gid 10001 baiyue \
|
||||||
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin baiyue \
|
&& useradd --uid 10001 --gid 10001 --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin baiyue \
|
||||||
@@ -16,13 +22,15 @@ COPY logistics_chat/ ./logistics_chat/
|
|||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
|
COPY deploy/__init__.py deploy/wsgi.py ./deploy/
|
||||||
|
COPY scripts/import_order_test_data.py scripts/import_quote_data.py ./scripts/
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
|
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", "-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"]
|
CMD ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--threads=8", "--channel-timeout=300", "--max-request-body-size=12582912", "--url-scheme=https", "--trusted-proxy=*", "--trusted-proxy-count=1", "--trusted-proxy-headers=x-forwarded-for", "--call", "deploy.wsgi:create_app"]
|
||||||
|
|
||||||
FROM runtime AS test
|
FROM runtime AS test
|
||||||
|
COPY Dockerfile ./
|
||||||
COPY deploy/ ./deploy/
|
COPY deploy/ ./deploy/
|
||||||
COPY tests/ ./tests/
|
COPY tests/ ./tests/
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
|
|||||||
Vendored
+34
-5
@@ -20,6 +20,15 @@ pipeline {
|
|||||||
if (!(params.PYTHON_BASE_IMAGE ==~ /[A-Za-z0-9][A-Za-z0-9._\/:@-]+/)) {
|
if (!(params.PYTHON_BASE_IMAGE ==~ /[A-Za-z0-9][A-Za-z0-9._\/:@-]+/)) {
|
||||||
error('Invalid Python base image reference')
|
error('Invalid Python base image reference')
|
||||||
}
|
}
|
||||||
|
if (!((params.ASR_PROVIDER ?: '') in ['', 'baidu'])) {
|
||||||
|
error('Gray speech provider must be baidu or disabled')
|
||||||
|
}
|
||||||
|
if (!((params.DEBIAN_MIRROR ?: 'https://deb.debian.org') in ['https://deb.debian.org', 'https://mirrors.tuna.tsinghua.edu.cn'])) {
|
||||||
|
error('Select a supported Debian mirror')
|
||||||
|
}
|
||||||
|
if (params.IMPORT_QUOTE_DATA && !(params.QUOTE_SNAPSHOT_CREDENTIAL_ID ==~ /[A-Za-z0-9_.-]+/)) {
|
||||||
|
error('Select the explicitly supplied private quote snapshot credential')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sh '''set -eu
|
sh '''set -eu
|
||||||
for file in deploy/*.sh; do bash -n "$file"; done
|
for file in deploy/*.sh; do bash -n "$file"; done
|
||||||
@@ -29,12 +38,12 @@ pipeline {
|
|||||||
}
|
}
|
||||||
stage('Build and test image') {
|
stage('Build and test image') {
|
||||||
steps {
|
steps {
|
||||||
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}"]) {
|
withEnv(["PYTHON_BASE_IMAGE=${params.PYTHON_BASE_IMAGE}", "DEBIAN_MIRROR=${params.DEBIAN_MIRROR ?: 'https://deb.debian.org'}"]) {
|
||||||
sh '''set -eu
|
sh '''set -eu
|
||||||
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" -t "$CHAT_IMAGE-test" .
|
docker build --target test --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" -t "$CHAT_IMAGE-test" .
|
||||||
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
docker build --target production --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
|
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CHAT_IMAGE" .
|
||||||
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" \
|
docker build --target certbot --build-arg PYTHON_BASE_IMAGE="$PYTHON_BASE_IMAGE" --build-arg DEBIAN_MIRROR="$DEBIAN_MIRROR" \
|
||||||
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
|
--label "org.opencontainers.image.revision=$SOURCE_COMMIT" -t "$CERTBOT_IMAGE" .
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
@@ -50,16 +59,36 @@ pipeline {
|
|||||||
withEnv([
|
withEnv([
|
||||||
"PUBLIC_ORIGIN=${params.PUBLIC_ORIGIN}", "HOST_PORT=${params.HOST_PORT}",
|
"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_DEPLOY_DIR=${params.GRAY_DEPLOY_DIR}", "GRAY_PROXY_CONF_DIR=${params.GRAY_PROXY_CONF_DIR}",
|
||||||
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}"
|
"GRAY_TLS_CERT_FILE=${params.GRAY_TLS_CERT_FILE}", "GRAY_TLS_KEY_FILE=${params.GRAY_TLS_KEY_FILE}",
|
||||||
|
"ASR_PROVIDER=${params.ASR_PROVIDER ?: ''}"
|
||||||
]) {
|
]) {
|
||||||
withCredentials([
|
withCredentials([
|
||||||
sshUserPrivateKey(credentialsId: params.SSH_CREDENTIAL_ID, keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
|
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'),
|
string(credentialsId: params.SSH_HOST_CREDENTIAL_ID, variable: 'TARGET_SSH_HOST'),
|
||||||
file(credentialsId: params.SSH_KNOWN_HOSTS_CREDENTIAL_ID, variable: 'TARGET_SSH_KNOWN_HOSTS'),
|
file(credentialsId: params.SSH_KNOWN_HOSTS_CREDENTIAL_ID, variable: 'TARGET_SSH_KNOWN_HOSTS'),
|
||||||
string(credentialsId: params.DEEPSEEK_CREDENTIAL_ID, variable: 'DEEPSEEK_API_KEY'),
|
string(credentialsId: params.DEEPSEEK_CREDENTIAL_ID, variable: 'DEEPSEEK_API_KEY'),
|
||||||
|
string(credentialsId: params.BAIDU_ASR_API_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_API_KEY'),
|
||||||
|
string(credentialsId: params.BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID, variable: 'BAIDU_ASR_SECRET_KEY'),
|
||||||
usernamePassword(credentialsId: params.PREVIEW_AUTH_CREDENTIAL_ID, usernameVariable: 'PREVIEW_USERNAME', passwordVariable: 'PREVIEW_PASSWORD')
|
usernamePassword(credentialsId: params.PREVIEW_AUTH_CREDENTIAL_ID, usernameVariable: 'PREVIEW_USERNAME', passwordVariable: 'PREVIEW_PASSWORD')
|
||||||
]) {
|
]) {
|
||||||
sh 'bash deploy/publish-gray.sh'
|
sh 'bash deploy/publish-gray.sh'
|
||||||
|
script {
|
||||||
|
if (params.IMPORT_QUOTE_DATA) {
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: params.QUOTE_SNAPSHOT_CREDENTIAL_ID, variable: 'QUOTE_SNAPSHOT_FILE')
|
||||||
|
]) {
|
||||||
|
sh 'bash deploy/import-gray-quotes.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.IMPORT_ORDER_SAMPLES) {
|
||||||
|
withCredentials([
|
||||||
|
file(credentialsId: params.ORDER_SNAPSHOT_CREDENTIAL_ID, variable: 'ORDER_SNAPSHOT_FILE'),
|
||||||
|
file(credentialsId: params.TRACKING_SNAPSHOT_CREDENTIAL_ID, variable: 'TRACKING_SNAPSHOT_FILE')
|
||||||
|
]) {
|
||||||
|
sh 'bash deploy/import-gray-order-samples.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 柏悦智能物流客服系统
|
# 柏悦智能物流客服系统
|
||||||
|
|
||||||
基于 Flask + DeepSeek 的中欧/英国跨境物流智能客服,支持多轮对话、运费试算、运单查询、历史会话管理。
|
基于 Flask + DeepSeek 的跨境物流智能客服,支持美国通用报价、中欧/英国运费试算、多轮对话、运单查询、历史会话管理。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
@@ -8,11 +8,12 @@
|
|||||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||||
- **运单号查询**:发送 8~20 位运单号,返回轨迹卡片;先展示最近节点,可展开全部轨迹
|
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||||
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
|
- **异常处理**:接口超时、Key 失效、中断时给出明确提示与重试
|
||||||
|
- **会话恢复**:连接中断保留已收到的正文;切换话题取消旧附件,等待建会话时保留新草稿;历史加载失败保留已有列表
|
||||||
- **深色模式**:右上角切换,偏好本地保存
|
- **深色模式**:右上角切换,偏好本地保存
|
||||||
- **响应式**:窄屏下侧栏自动变为抽屉
|
- **响应式**:窄屏下侧栏自动变为抽屉
|
||||||
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
|
- **附件预览**:发送前查看文件名或缩略图,可移除待发送附件
|
||||||
@@ -21,8 +22,21 @@
|
|||||||
|
|
||||||
## 报价数据
|
## 报价数据
|
||||||
|
|
||||||
|
美国通用报价通过 `scripts/import_quote_data.py` 将本地 Excel 明细导出为私有快照,再导入运行 SQLite;不读取「快速查询」的外部工作簿,不按文件日期或旧船期停用。原始文件、价格快照及运行数据库均不进入 Git 或镜像。
|
||||||
|
|
||||||
|
`quote_books` 保存版本,`quote_rates` 保存线路/交货仓/目的仓或分区/税费方式/重量档位/计费单位,`quote_warehouses` 保留仓码的多种分区,`quote_postcodes` 保存带前导零的邮编及偏远类别,`quote_documents` 保存当前资料中的业务说明。每条价格保留源工作表和单元格。指定 `--activate` 才原子切换美国报价版本,导入不修改订单和客户范围。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/import_quote_data.py --xlsx <本地报价.xlsx> --output <新私有快照.json>
|
||||||
|
python scripts/import_quote_data.py --snapshot <私有快照.json> --db <运行数据库.db> --activate
|
||||||
|
```
|
||||||
|
|
||||||
|
美国报价先由本地规则计算并通过既有 SSE 返回,不等待模型调用。仅给「美国30KG」可先看到不同海派、交货仓和美西/美中/美东的基础价格;提供仓码、邮编、箱数和尺寸后在当前会话继续核算。同一仓码有多种分区时并列展示;更换目的仓或邮编后不重用旧目的地。体积、附加费和承运限制尚不明确时继续补充条件,不编造缺失费率。现有欧洲/英国报价使用下述原有来源。
|
||||||
|
|
||||||
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
|
代码标注的报价来源为 **炜易达 VIP 报价表(2026-10-15 9:00 执行版)**,覆盖 8 条渠道。以下逐渠道列出代码中的重量档位;按计费重量选择不大于该重量的最高档位:
|
||||||
|
|
||||||
|
报价试算需先确认目的国家或地区;未识别到目的地时先补充信息。英国报价及其连续追问使用英国费率,“海运呢”“空运呢”等短句和重试保留原有条件。美国使用已激活的本地价表;未导入美国价表或其他未覆盖目的地时补充条件或由人工确认。英国按方报价不套用欧洲仓价。没有匹配的国家或仓点时不自动回退到其他仓点。
|
||||||
|
|
||||||
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|
| 渠道 | 重量档位 → 基础单价(元/kg) | 参考时效 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
|
| 欧洲海运快线 | 21KG+ → 12.3;101KG+ → 10.3;500KG+ → 10.2;2000KG+ → 10.0 | 开船后 38-45 天 |
|
||||||
@@ -104,6 +118,10 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
|
|||||||
|
|
||||||
### 配置说明
|
### 配置说明
|
||||||
|
|
||||||
|
手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 60 秒。微信和手机浏览器使用同一服务端转写链路;页面必须通过 HTTPS 或本机 localhost 打开,并允许麦克风。旧版内置浏览器不支持录音时仍可文字输入。手机订单信息默认折叠,物流轨迹优先展示。
|
||||||
|
|
||||||
|
在根目录 `.env` 配置 `ASR_PROVIDER=baidu`、`BAIDU_ASR_API_KEY` 和 `BAIDU_ASR_SECRET_KEY`,使用已开通短语音识别的百度语音应用凭据,不能混用 OCR 或 DeepSeek 凭据。服务端须安装 FFmpeg 并加入 PATH,也可用 `FFMPEG_BIN` 指定可执行文件绝对路径;Docker 应用镜像已包含 FFmpeg。浏览器 WebM/MP4 录音通过内存管道转为 16 kHz 单声道 PCM,录音不写入上传目录或历史,只发送识别文字。修改后重启服务。可选的 `ASR_PROVIDER=dashscope` 使用 `.env.example` 中独立的 ASR 配置。
|
||||||
|
|
||||||
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
|
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
|
||||||
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
|
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
|
||||||
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
|
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
|
||||||
@@ -114,7 +132,7 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过
|
|||||||
|
|
||||||
### Jenkins 灰度部署
|
### Jenkins 灰度部署
|
||||||
|
|
||||||
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,再配置独立域名的 HTTPS 和全站访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。面向客户开放前仍需补齐鉴权和数据隔离。另附 [Linux 单机参考](docs/deployment-linux.md)。
|
现有灰度环境按 [Jenkins 部署指南](docs/deployment.md) 发布:构建和测试镜像,SSH 发送至专用 Compose 服务,Nginx 提供 HTTPS。客户聊天入口免登录,只有明确列出的客户 API 开放;客服后台、搜索、导出和其他未知路径继续使用访问密码。密钥由 Jenkins 注入,数据和证书保存在独立宿主机目录;启动失败时恢复之前的应用和站点。上游端口必须仅绑定回环地址。另附 [Linux 单机参考](docs/deployment-linux.md)。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -132,7 +150,8 @@ baiyue-logistics-chat/
|
|||||||
│ ├── knowledge.py 知识与关键词检索
|
│ ├── knowledge.py 知识与关键词检索
|
||||||
│ ├── pricing.py 运费解析与计算
|
│ ├── pricing.py 运费解析与计算
|
||||||
│ ├── rates_extra.py 扩展报价唯一数据源
|
│ ├── rates_extra.py 扩展报价唯一数据源
|
||||||
│ ├── tracking.py 模拟轨迹与卡片协议
|
│ ├── tracking.py 卡片协议与旧演示函数兼容
|
||||||
|
│ ├── order_data.py 本地订单测试表、精确查询与快照导入
|
||||||
│ ├── ai_client.py 模型 HTTP、压缩和追问
|
│ ├── ai_client.py 模型 HTTP、压缩和追问
|
||||||
│ ├── chat_service.py 问答与 SSE 编排
|
│ ├── chat_service.py 问答与 SSE 编排
|
||||||
│ ├── history_service.py 会话 CRUD
|
│ ├── history_service.py 会话 CRUD
|
||||||
@@ -238,14 +257,36 @@ data: {"d": 1}
|
|||||||
|
|
||||||
## 数据库与运行数据
|
## 数据库与运行数据
|
||||||
|
|
||||||
首次启动会自动创建 `chat_history`、`messages`、`feedback`、`human_sessions`、`human_messages`、`ratings`、`agents`、`orders` 和 `tickets` 九张表,并兼容已有数据,不预置账号或演示数据。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。客服账号需由管理员在实际环境中配置。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。运单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
订单数据保存在聊天服务运行时 SQLite 的 `order_test_data` 表中,与灰度业务库独立,可直接通过 SQLite 工具新增或修改;不会自动同步源库,没有管理页面和对外增删改接口。游客咨询无需登录,查询订单时引导客户登录;查询按服务端账号关联的客户范围过滤。`ORDER_DATA_TENANT_ID` 是额外限制,不能代替客户身份。
|
||||||
|
|
||||||
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库并清理修改时间超过 7 天的上传文件。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
默认前台客户账号为 `admin / 123456`,与受 Nginx 保护的客服后台独立。默认按当前本地数据建立客户范围关联:单客户写入 `customer_users.tenant_id`,多客户写入 `customer_tenant_grants`,查询始终按服务端白名单过滤。首次关联完成后不自动加入新客户范围,同一客户下新增订单可直接查询。`CUSTOMER_DEMO_TENANT_ID` 可指定单个范围;`CUSTOMER_DEMO_LOCAL_ORDERS=false` 关闭多客户自动关联。账号保存在 `customer_users`,密码为 PBKDF2 哈希;已有账号启动时不覆盖,可直接维护哈希、`tenant_id`、`enabled` 及客户范围关联表。`CUSTOMER_DEMO_ENABLED=false` 禁用默认账号及其已有登录;真实客户账号不受该开关影响。`CUSTOMER_DEMO_USERNAME` 与 `CUSTOMER_DEMO_PASSWORD_HASH` 用于首次创建默认账号。
|
||||||
|
|
||||||
|
登录凭据通过独立 HttpOnly Cookie 及服务端 `customer_sessions` 校验,绑定当前访客,8 小时有效;退出后撤销当前登录。登录后自动继续刚才的订单问题;含订单事实的会话在退出后隐藏,只有相同访客重新登录同一客户和范围后才能恢复。真实用户认证接入时替换账号校验/关联层,继续使用内部客户 ID、可信客户范围和已有登录会话契约;MCP 数据查询仍通过 `lookup_order` 的客户范围过滤。
|
||||||
|
|
||||||
|
游客首次访问由浏览器生成 256 位随机 key,保存在当前站点的 localStorage;服务端只保存其哈希作为会话归属,以 HttpOnly、SameSite Cookie 校验请求。刷新或同浏览器再次打开可恢复自己的历史,其他浏览器拥有独立记录;清除站点数据后无法自动找回。存储被禁用时使用 Cookie 维持访客会话。旧版未记录归属的历史不自动分配给首位访客,继续保留给受保护的客服后台。访客只能访问、删除自己的聊天、人工会话和附件,不能凭会话编号或访客 key 查询真实订单。
|
||||||
|
|
||||||
|
未发送的文字草稿按游客和会话分别保存在当前浏览器的 localStorage,当前标签页通过 sessionStorage 记录上次打开的会话;切换会话和刷新可恢复对应草稿,发送或删除会话后清理。草稿不跨设备同步,不恢复未发送的附件。浏览器禁止存储或空间不足时只在当前页面暂存,并提示刷新前保留文字;清除站点数据会同时删除草稿。历史加载失败期间禁止发送,重新选择会话或新建对话后再继续。
|
||||||
|
|
||||||
|
快照 JSON 放仓库外的私有 artifacts 目录,使用明确路径导入:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv\Scripts\python.exe scripts/import_order_test_data.py --snapshot "D:\private\orders.snapshot.json" --db "D:\data\history.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本默认保留已有记录,避免覆盖手工调整;确需覆盖时显式加 `--replace`。`order_no` 与 `tenant_id` 组成唯一键;`gross_weight_kg`、`volume_cbm`、`package_count` 是申报汇总,`history_json` 是形如 `[{"time":"2026-10-29 10:00","status":"SUBMITTED"}]` 的业务状态列表。子订单未提供独立货物数据时保持空值。快照、运行数据库和凭据不提交 Git。后续 MCP 接口开放后替换 `order_data.lookup_order`、`count_orders` 的取数实现即可保留聊天契约。
|
||||||
|
|
||||||
|
登录后可以直接问“我有多少个运单”“我的订单数量”“已完成的订单有多少”。统计每次按当前账号的授权范围查表,返回订单票数和其中已登记入仓码的票数;不要求先提供入仓码,也不沿用上一票订单作为统计范围。支持订单总量和明确的业务状态;其他筛选条件不冒充总量返回,头程订单完成不等于签收。统计会话与订单卡片一样绑定客户和范围,退出后隐藏,重新登录同一身份及范围后恢复。
|
||||||
|
|
||||||
|
点击“查入仓码”会打开查询框,填写号码后查询;游客先登录。回复及历史卡片中的入仓码也可点击,自动带入号码,可复制或查询最新轨迹。查询结果接在当前聊天中,草稿和待发送附件保留,人工接待中需要先返回 AI 问答。
|
||||||
|
|
||||||
|
首次启动创建聊天、访客附件、客户登录、人工接待、反馈及订单所需表,并兼容已有数据。默认前台客户账号按上述配置初始化,客服账号和订单数据由管理员配置。本地入口使用根目录 `history.db`,Linux 部署工厂另存至专用数据目录。会话备注保存在 `human_sessions.note`;消息撤回事件通过 `human_messages.recalled_id` 指向原消息,轮询客户端按编号更新。订单卡片以 `__CARD__{json}` 形式保存,前端据此恢复展示。
|
||||||
|
|
||||||
|
普通导入 `am.py` 或 `logistics_chat` 不创建运行数据。显式调用 `create_app()` 或访问 WSGI 兼容入口 `am.app` 时,默认初始化 SQLite、创建正常运行日志、备份数据库,并清理未被聊天记录引用且修改时间超过 7 天的临时上传文件。已发送附件随 AI/人工聊天记录保留;已被删除的旧文件无法恢复,页面会显示附件无法访问的提示。测试须事先配置临时数据库、附件、日志和备份目录;`INITIALIZE_RUNTIME=False` 可禁止自动初始化,此时调用方负责准备 schema。历史会话的自动清理仍已关闭。
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
- 运单轨迹为模拟数据,尚未接入真实物流查询接口。
|
- 订单查询使用可编辑测试副本,尚未接入实时承运商物流轨迹或 MCP;旧历史卡片继续兼容展示。
|
||||||
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。公网自用验收可按部署指南使用全站访问密码进入工作台。
|
- 不预置客服账号;应用内客服登录仍沿用旧账号校验逻辑。客服工作台及接口仍须通过 Nginx 的专用访问密码保护,客户聊天不要求此密码。
|
||||||
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
- 后台登录尚未建立完整会话鉴权,管理页面和接口缺少登录校验。
|
||||||
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
- 运费数字由 Python 计算后交给模型组织说明;附加费是否适用仍需结合具体货物和地址核对。
|
||||||
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
|
- 回答提示词目标为 120–350 字;代码压缩阈值为中文 800、英文 1400 字符,超过阈值最多尝试压缩两次。
|
||||||
|
|||||||
+26
-5
@@ -12,7 +12,7 @@ release=${2:?}
|
|||||||
# Parse a small allowlist of non-secret release fields; never source a credential file.
|
# Parse a small allowlist of non-secret release fields; never source a credential file.
|
||||||
while IFS='=' read -r name value; do
|
while IFS='=' read -r name value; do
|
||||||
case "$name" in
|
case "$name" in
|
||||||
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY) ;;
|
CHAT_IMAGE|CHAT_IMAGE_ID|CHAT_CERTBOT_IMAGE|CHAT_CERTBOT_IMAGE_ID|SOURCE_COMMIT|HOST_PORT|CHAT_ROOT|CHAT_RELEASE|CHAT_DATA_DIR|CHAT_LOG_DIR|CHAT_RUNTIME_ENV|CHAT_DOMAIN|CHAT_PROXY_DIR|CHAT_TLS_CERT|CHAT_TLS_KEY|CHAT_AUTH_DIR) ;;
|
||||||
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
|
*) echo 'Unexpected release setting.' >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
|
[[ "$value" =~ ^[A-Za-z0-9_./:-]+$ ]] || exit 2
|
||||||
@@ -43,7 +43,8 @@ if [[ -n "$(ss -H -ltn "sport = :$HOST_PORT")" && "$old_port" != "127.0.0.1:$HOS
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
|
site="$CHAT_PROXY_DIR/baiyue-logistics-chat-gray.conf"
|
||||||
auth="$CHAT_PROXY_DIR/.baiyue-logistics-chat.htpasswd"
|
[[ "$CHAT_AUTH_DIR" == /*/baiyue-logistics-chat-gray-proxy && "$CHAT_AUTH_DIR" != *..* ]] || exit 2
|
||||||
|
auth="$CHAT_AUTH_DIR/access.htpasswd"
|
||||||
if [[ -f "$site" ]] && ! head -n 1 "$site" | grep -Fq 'Managed for baiyue-logistics-chat-gray'; then
|
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
|
echo 'The proxy file is not owned by this application.' >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -55,6 +56,13 @@ for candidate in "$CHAT_PROXY_DIR/"*.conf; do
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
done
|
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=''
|
previous=''
|
||||||
if [[ -f "$root/current-release" ]]; then
|
if [[ -f "$root/current-release" ]]; then
|
||||||
previous=$(cat "$root/current-release")
|
previous=$(cat "$root/current-release")
|
||||||
@@ -103,17 +111,30 @@ install -m 644 "$release/site.htpasswd" "$auth"
|
|||||||
install -m 644 "$release/site.conf" "$site"
|
install -m 644 "$release/site.conf" "$site"
|
||||||
nginx -t
|
nginx -t
|
||||||
nginx -s reload
|
nginx -s reload
|
||||||
for path in / /api/export_chat /api/get_history; do
|
for check in /:200 /admin/dashboard:401 /api/export_chat:401 /api/get_history:401 /api/human/queue:401; do
|
||||||
|
path=${check%:*}
|
||||||
|
expected=${check##*:}
|
||||||
code=''
|
code=''
|
||||||
# A successful reload signal precedes the new workers accepting connections.
|
# A successful reload signal precedes the new workers accepting connections.
|
||||||
for attempt in {1..10}; do
|
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
|
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
|
[[ "$code" != "$expected" ]] || break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
[[ "$code" == 401 ]] || { echo 'Gray access protection verification failed.' >&2; false; }
|
[[ "$code" == "$expected" ]] || { echo 'Guest/staff access verification failed.' >&2; false; }
|
||||||
done
|
done
|
||||||
|
visitor_cookie=$(mktemp "$release/.visitor-smoke.XXXXXX")
|
||||||
|
trap 'rm -f -- "$visitor_cookie"' EXIT
|
||||||
|
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
|
||||||
|
-c "$visitor_cookie" -o /dev/null -w '%{http_code}' -H 'Content-Type: application/json' \
|
||||||
|
-d '{}' "https://$CHAT_DOMAIN/api/visitor/session")
|
||||||
|
[[ "$code" == 200 ]] || { echo 'Guest session bootstrap verification failed.' >&2; false; }
|
||||||
|
code=$(curl --silent --show-error --max-time 10 --resolve "$CHAT_DOMAIN:443:127.0.0.1" \
|
||||||
|
-b "$visitor_cookie" -o /dev/null -w '%{http_code}' "https://$CHAT_DOMAIN/api/get_history")
|
||||||
|
[[ "$code" == 200 ]] || { echo 'Guest history verification failed.' >&2; false; }
|
||||||
|
rm -f -- "$visitor_cookie"
|
||||||
|
trap - EXIT
|
||||||
printf '%s\n' "$release" > "$root/.current-release.new"
|
printf '%s\n' "$release" > "$root/.current-release.new"
|
||||||
mv -f "$root/.current-release.new" "$root/current-release"
|
mv -f "$root/.current-release.new" "$root/current-release"
|
||||||
trap - ERR
|
trap - ERR
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Group=baiyue
|
|||||||
WorkingDirectory=/opt/baiyue-logistics-chat
|
WorkingDirectory=/opt/baiyue-logistics-chat
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=PYTHONDONTWRITEBYTECODE=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
|
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 --trusted-proxy=127.0.0.1 --trusted-proxy-count=1 --trusted-proxy-headers=x-forwarded-for --call deploy.wsgi:create_app
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
TimeoutStopSec=90
|
TimeoutStopSec=90
|
||||||
|
|||||||
+54
-20
@@ -29,6 +29,7 @@ class GrayConfig:
|
|||||||
image: str
|
image: str
|
||||||
image_id: str
|
image_id: str
|
||||||
certbot_image_id: str
|
certbot_image_id: str
|
||||||
|
auth_dir: str = '/var/lib/baiyue-logistics-chat-gray-proxy'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_environment(cls, environment):
|
def from_environment(cls, environment):
|
||||||
@@ -70,11 +71,12 @@ class GrayConfig:
|
|||||||
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
|
'CHAT_RUNTIME_ENV': self.release + '/runtime.env',
|
||||||
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
|
'CHAT_DOMAIN': self.domain, 'CHAT_PROXY_DIR': self.proxy_dir,
|
||||||
'CHAT_TLS_CERT': self.certificate, 'CHAT_TLS_KEY': self.private_key,
|
'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())
|
return ''.join(f'{key}={value}\n' for key, value in fields.items())
|
||||||
|
|
||||||
def nginx_config(self):
|
def nginx_config(self):
|
||||||
return f'''# Managed for baiyue-logistics-chat-gray; all application routes require authentication.
|
return f'''# Managed for baiyue-logistics-chat-gray; guest chat is public and staff routes require authentication.
|
||||||
server {{
|
server {{
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name {self.domain};
|
server_name {self.domain};
|
||||||
@@ -91,35 +93,67 @@ server {{
|
|||||||
ssl_certificate_key {self.private_key};
|
ssl_certificate_key {self.private_key};
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
auth_basic "Baiyue gray acceptance";
|
auth_basic "Baiyue gray acceptance";
|
||||||
auth_basic_user_file {self.proxy_dir}/.baiyue-logistics-chat.htpasswd;
|
auth_basic_user_file {self.auth_dir}/access.htpasswd;
|
||||||
client_max_body_size 12m;
|
client_max_body_size 12m;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Authorization "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
gzip off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
location = / {{
|
||||||
|
auth_basic off;
|
||||||
|
proxy_pass http://127.0.0.1:{self.port};
|
||||||
|
}}
|
||||||
|
location ^~ /static/ {{
|
||||||
|
auth_basic off;
|
||||||
|
proxy_pass http://127.0.0.1:{self.port};
|
||||||
|
}}
|
||||||
|
location ^~ /uploads/ {{
|
||||||
|
auth_basic off;
|
||||||
|
proxy_pass http://127.0.0.1:{self.port};
|
||||||
|
}}
|
||||||
|
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {{
|
||||||
|
auth_basic off;
|
||||||
|
proxy_pass http://127.0.0.1:{self.port};
|
||||||
|
}}
|
||||||
location / {{
|
location / {{
|
||||||
proxy_pass http://127.0.0.1:{self.port};
|
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):
|
def runtime_environment(key, speech=None):
|
||||||
if not key.strip() or any(character in key for character in ('\r', '\n', '\x00')):
|
fields = {'DEEPSEEK_API_KEY': key}
|
||||||
raise ValueError('The model credential must be a nonempty single line')
|
speech = speech or {}
|
||||||
|
provider = (speech.get('ASR_PROVIDER') or '').strip()
|
||||||
|
if provider:
|
||||||
|
if provider != 'baidu':
|
||||||
|
raise ValueError('Gray speech provider must be baidu or disabled')
|
||||||
|
fields.update(ASR_PROVIDER=provider,
|
||||||
|
BAIDU_ASR_API_KEY=speech.get('BAIDU_ASR_API_KEY', ''),
|
||||||
|
BAIDU_ASR_SECRET_KEY=speech.get('BAIDU_ASR_SECRET_KEY', ''))
|
||||||
|
for value in fields.values():
|
||||||
|
if not isinstance(value, str) or not value.strip() or any(c in value for c in ('\r', '\n', '\x00')):
|
||||||
|
raise ValueError('Runtime credentials must be nonempty single lines')
|
||||||
|
def literal(value):
|
||||||
|
return "'" + value.strip().replace('\\', '\\\\').replace("'", "\\'") + "'"
|
||||||
|
return ''.join(name + '=' + literal(value) + '\n' for name, value in fields.items())
|
||||||
|
|
||||||
|
|
||||||
|
def write_release(directory, config, key, speech=None):
|
||||||
|
runtime = runtime_environment(key, speech)
|
||||||
directory.mkdir(parents=True, exist_ok=False)
|
directory.mkdir(parents=True, exist_ok=False)
|
||||||
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
|
(directory / 'release.env').write_text(config.release_environment(), encoding='utf-8')
|
||||||
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
|
(directory / 'site.conf').write_text(config.nginx_config(), encoding='utf-8')
|
||||||
escaped = key.strip().replace('\\', '\\\\').replace("'", "\\'")
|
(directory / 'runtime.env').write_text(runtime, encoding='utf-8')
|
||||||
(directory / 'runtime.env').write_text("DEEPSEEK_API_KEY='" + escaped + "'\n", encoding='utf-8')
|
|
||||||
(directory / 'runtime.env').chmod(0o600)
|
(directory / 'runtime.env').chmod(0o600)
|
||||||
source = Path(__file__).resolve().parent
|
source = Path(__file__).resolve().parent
|
||||||
shutil.copyfile(source / 'compose.gray.yml', directory / 'compose.yml')
|
shutil.copyfile(source / 'compose.gray.yml', directory / 'compose.yml')
|
||||||
@@ -134,7 +168,7 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
config = GrayConfig.from_environment(os.environ)
|
config = GrayConfig.from_environment(os.environ)
|
||||||
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'])
|
write_release(args.directory, config, os.environ['DEEPSEEK_API_KEY'], os.environ)
|
||||||
except (KeyError, ValueError) as error:
|
except (KeyError, ValueError) as error:
|
||||||
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
|
parser.exit(2, 'Gray release configuration is incomplete or invalid.\n')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
: "${ORDER_SNAPSHOT_FILE:?}" "${TRACKING_SNAPSHOT_FILE:?}"
|
||||||
|
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||||
|
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
|
||||||
|
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
|
||||||
|
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
|
||||||
|
root=$GRAY_DEPLOY_DIR
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
|
||||||
|
release="$root/releases/$release_id"
|
||||||
|
order_sha=$(sha256sum "$ORDER_SNAPSHOT_FILE" | awk '{print $1}')
|
||||||
|
tracking_sha=$(sha256sum "$TRACKING_SNAPSHOT_FILE" | awk '{print $1}')
|
||||||
|
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
|
||||||
|
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
|
||||||
|
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||||
|
|
||||||
|
# Names are fixed; credentials and snapshot contents never enter the shell log.
|
||||||
|
scp "${ssh_options[@]}" "$ORDER_SNAPSHOT_FILE" "$target:$release/order-input.json"
|
||||||
|
scp "${ssh_options[@]}" "$TRACKING_SNAPSHOT_FILE" "$target:$release/tracking-input.json"
|
||||||
|
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$order_sha" "$tracking_sha" <<'IMPORT'
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
root=$1
|
||||||
|
release_id=$2
|
||||||
|
source_commit=$3
|
||||||
|
order_sha=$4
|
||||||
|
tracking_sha=$5
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
||||||
|
[[ "$order_sha" =~ ^[a-f0-9]{64}$ && "$tracking_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
release="$root/releases/$release_id"
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray && "$(cat "$root/current-release")" == "$release" ]] || exit 2
|
||||||
|
container=baiyue-logistics-chat-gray
|
||||||
|
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
|
||||||
|
printf '%s %s\n' "$order_sha" "$release/order-input.json" "$tracking_sha" "$release/tracking-input.json" | sha256sum --check --status
|
||||||
|
input="$root/data/imports/$release_id"
|
||||||
|
[[ ! -e "$input" ]] || { echo 'Existing import input requires inspection.' >&2; exit 2; }
|
||||||
|
cleanup() {
|
||||||
|
rm -f -- "$input/orders.json" "$input/tracking.json" "$release/order-input.json" "$release/tracking-input.json"
|
||||||
|
rmdir -- "$input" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
|
||||||
|
install -m 600 -o 10001 -g 10001 "$release/order-input.json" "$input/orders.json"
|
||||||
|
install -m 600 -o 10001 -g 10001 "$release/tracking-input.json" "$input/tracking.json"
|
||||||
|
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-order-import-"+sys.argv[1]+".db"); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before sample import.")' "$release_id"
|
||||||
|
docker exec "$container" python scripts/import_order_test_data.py \
|
||||||
|
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/orders.json"
|
||||||
|
docker exec "$container" python scripts/import_order_test_data.py \
|
||||||
|
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/$release_id/tracking.json" --tracking-only
|
||||||
|
echo 'Private snapshots imported with existing order fields and nonempty tracking preserved.'
|
||||||
|
IMPORT
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
: "${QUOTE_SNAPSHOT_FILE:?}"
|
||||||
|
: "${TARGET_SSH_KEY:?}" "${TARGET_SSH_USER:?}" "${TARGET_SSH_HOST:?}" "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||||
|
: "${GRAY_DEPLOY_DIR:?}" "${SOURCE_COMMIT:?}" "${BUILD_NUMBER:?}"
|
||||||
|
[[ "$TARGET_SSH_USER" =~ ^[A-Za-z0-9_.-]+$ && "$TARGET_SSH_HOST" =~ ^[A-Za-z0-9.-]+$ ]] || exit 2
|
||||||
|
[[ "$SOURCE_COMMIT" =~ ^[a-f0-9]{40}$ && "$BUILD_NUMBER" =~ ^[1-9][0-9]*$ ]] || exit 2
|
||||||
|
root=$GRAY_DEPLOY_DIR
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
release_id="${SOURCE_COMMIT:0:12}-b$BUILD_NUMBER"
|
||||||
|
release="$root/releases/$release_id"
|
||||||
|
quote_sha=$(sha256sum "$QUOTE_SNAPSHOT_FILE" | awk '{print $1}')
|
||||||
|
ssh_options=(-i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes
|
||||||
|
-o "UserKnownHostsFile=$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12)
|
||||||
|
target="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||||
|
scp "${ssh_options[@]}" "$QUOTE_SNAPSHOT_FILE" "$target:$release/quote-input.json"
|
||||||
|
ssh "${ssh_options[@]}" "$target" bash -s -- "$root" "$release_id" "$SOURCE_COMMIT" "$quote_sha" <<'IMPORT'
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 077
|
||||||
|
root=$1
|
||||||
|
release_id=$2
|
||||||
|
source_commit=$3
|
||||||
|
quote_sha=$4
|
||||||
|
[[ "$root" == /*/gray-logistics-chat && "$root" != *..* ]] || exit 2
|
||||||
|
[[ "$release_id" =~ ^[a-f0-9]{12}-b[0-9]+$ && "$source_commit" =~ ^[a-f0-9]{40}$ ]] || exit 2
|
||||||
|
[[ "$quote_sha" =~ ^[a-f0-9]{64}$ ]] || exit 2
|
||||||
|
release="$root/releases/$release_id"
|
||||||
|
[[ "$(cat "$root/.project")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
input="$root/data/imports/quotes-$release_id"
|
||||||
|
owned_input=0
|
||||||
|
cleanup() {
|
||||||
|
rm -f -- "$release/quote-input.json"
|
||||||
|
if [[ "$owned_input" == 1 ]]; then
|
||||||
|
rm -f -- "$input/quotes.json"
|
||||||
|
rmdir -- "$input" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
[[ "$(cat "$root/current-release")" == "$release" ]] || exit 2
|
||||||
|
container=baiyue-logistics-chat-gray
|
||||||
|
[[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container")" == baiyue-logistics-chat-gray ]] || exit 2
|
||||||
|
[[ "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container")" == "$source_commit" ]] || exit 2
|
||||||
|
printf '%s %s\n' "$quote_sha" "$release/quote-input.json" | sha256sum --check --status
|
||||||
|
[[ ! -e "$input" ]] || { echo 'Existing quote import input requires inspection.' >&2; exit 2; }
|
||||||
|
install -d -m 700 -o 10001 -g 10001 "$root/data/imports" "$input"
|
||||||
|
owned_input=1
|
||||||
|
install -m 600 -o 10001 -g 10001 "$release/quote-input.json" "$input/quotes.json"
|
||||||
|
docker exec "$container" python -c 'import pathlib,sqlite3,sys; p=pathlib.Path("/var/lib/baiyue-logistics-chat/backups")/("pre-quote-import-"+sys.argv[1]+".db"); p.parent.mkdir(exist_ok=True); assert not p.exists(); source=sqlite3.connect("/var/lib/baiyue-logistics-chat/history.db"); target=sqlite3.connect(str(p)); source.backup(target); target.close(); source.close(); print("Chat SQLite backup completed before quote import.")' "$release_id"
|
||||||
|
docker exec "$container" python scripts/import_quote_data.py \
|
||||||
|
--db /var/lib/baiyue-logistics-chat/history.db --snapshot "/var/lib/baiyue-logistics-chat/imports/quotes-$release_id/quotes.json" --activate
|
||||||
|
echo 'General quote version activated; order data and customer grants preserved.'
|
||||||
|
IMPORT
|
||||||
+20
-16
@@ -24,28 +24,32 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
# Guard all pages, API routes, attachments and history/export operations.
|
# Staff and unknown routes remain protected; guest APIs check their own visitor owner.
|
||||||
auth_basic "Baiyue private acceptance";
|
auth_basic "Baiyue private acceptance";
|
||||||
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
|
auth_basic_user_file /etc/nginx/.baiyue-preview.htpasswd;
|
||||||
|
|
||||||
# Allow multipart overhead around the application's 10 MiB file limit.
|
# Allow multipart overhead around the application's 10 MiB file limit.
|
||||||
client_max_body_size 12m;
|
client_max_body_size 12m;
|
||||||
|
|
||||||
location / {
|
proxy_http_version 1.1;
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_set_header Connection "";
|
||||||
proxy_http_version 1.1;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header Authorization "";
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_buffering off;
|
||||||
proxy_set_header Authorization "";
|
proxy_cache off;
|
||||||
|
gzip off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
# SSE must reach the browser as chunks arrive.
|
location = / { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||||
proxy_buffering off;
|
location ^~ /static/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||||
proxy_cache off;
|
location ^~ /uploads/ { auth_basic off; proxy_pass http://127.0.0.1:5000; }
|
||||||
gzip off;
|
location ~ ^/(?:healthz|stream_chat|api/(?:visitor/(?:session|human/(?:request|poll|send|close))|customer/(?:session|login|logout)|new_chat|get_history|get_messages|del_history|clear_all|upload|ticket|rate|feedback|speech/(?:config|transcribe)))$ {
|
||||||
proxy_read_timeout 300s;
|
auth_basic off;
|
||||||
proxy_send_timeout 300s;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
}
|
}
|
||||||
|
location / { proxy_pass http://127.0.0.1:5000; }
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-6
@@ -26,7 +26,9 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
|
|||||||
| 历史接口 | `logistics_chat/history_service.py`:`new_chat`、`get_history`、`get_messages`、`del_history`、`clear_all`;对应 `routes/history.py` | `static/js/chat.js`:`ensureConv`、`loadHistory`、`openConversation` |
|
| 历史接口 | `logistics_chat/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/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/pricing.py`:`RATES`、`parse_shipping_intent`、`dims_to_cm`、`pick_rate`、`calc_freight`、`freight_reply`、`calc_warehouse_card`、`detect_surcharge`、`check_island` | `logistics_chat/rates_extra.py` 为唯一扩展数据源,根目录同名文件仅兼容转发 |
|
||||||
| 运单轨迹 | `logistics_chat/tracking.py`:`extract_tracking_no`、`build_track`、`CARD_PREFIX`、`card_to_text` | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;模拟节点时间取当前时刻,回归对照需固定时钟 |
|
| 卡片协议 | `logistics_chat/tracking.py`:`CARD_PREFIX`、`card_to_text`;旧 `extract_tracking_no`、`build_track` 仅兼容旧导入 | `static/js/chat.js`:`fillTrackCard`、`addTrackCard`;新订单卡片由 `order_data.py` 提供,历史卡片保留兼容渲染 |
|
||||||
|
| 订单测试表 | `logistics_chat/order_data.py`:`init_order_table`、`import_records`、`lookup_order`、`count_orders`、`answer_query` | SQLite `order_test_data` 为可编辑快照;按订单号、入仓码、Shipment ID 精确查表。将来 MCP 在取数层替换 `lookup_order`、`count_orders`,不让模型拼 SQL |
|
||||||
|
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
|
||||||
| 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` |
|
| 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/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/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
|
||||||
@@ -49,6 +51,7 @@ logistics_chat/
|
|||||||
├── pricing.py # 运费规则、解析、换算、附加费说明
|
├── pricing.py # 运费规则、解析、换算、附加费说明
|
||||||
├── rates_extra.py # 扩展报价数据
|
├── rates_extra.py # 扩展报价数据
|
||||||
├── tracking.py # 单号识别、模拟轨迹、卡片协议
|
├── tracking.py # 单号识别、模拟轨迹、卡片协议
|
||||||
|
├── order_data.py # 可编辑订单测试表、精确查询、快照导入与卡片事实
|
||||||
├── ai_client.py # 模型 HTTP、压缩与追问
|
├── ai_client.py # 模型 HTTP、压缩与追问
|
||||||
├── chat_service.py # 问答编排与 SSE 帧
|
├── chat_service.py # 问答编排与 SSE 帧
|
||||||
├── history_service.py # 历史会话 CRUD
|
├── history_service.py # 历史会话 CRUD
|
||||||
@@ -85,7 +88,11 @@ tests/ # 按业务模块组织的回归
|
|||||||
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
|
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
|
||||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||||
|
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
|
||||||
|
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
|
||||||
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
- 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。
|
||||||
|
- 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。
|
||||||
|
- 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。
|
||||||
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
- 客户页 `resetConversation` 统一处理新建、快捷问题和历史切换;人工路由同时绑定 `humanConvId` 与当前会话版本。旧建会话、历史、人工申请和轮询的异步结果不得覆盖新话题。三个业务快捷入口开启独立 AI 对话,联系人工入口才进入人工模式;排队/接待状态常驻输入框上方,可用「返回 AI 问答」结束人工会话。人工消息确认成功后才清空草稿,失败保留输入;服务端校验可选 `conv_id` 并拒绝已关闭会话。
|
||||||
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
- 客服工作台默认只展示会话列表和全高聊天区;统计使用原生 `details` 收在「数据概览」。`setSessionTab` 切换接待/排队列表,`setChatView` 与 `showSessionList` 控制 720px 及以下的整屏导航,不结束当前会话。`setToolsOpen` 控制默认关闭的服务资料:1200px 以上为侧栏,较窄屏为抽屉;列表入口让未选会话的手机用户也能查看留言。
|
||||||
|
|
||||||
@@ -102,18 +109,38 @@ tests/ # 按业务模块组织的回归
|
|||||||
|
|
||||||
## 必须保留的接口与数据约定
|
## 必须保留的接口与数据约定
|
||||||
|
|
||||||
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`,保留流式输出、停止、异常及中断保存。
|
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
|
||||||
- `chat_service.create_chat_stream` 在识别运单号时忽略 `[图片]/uploads/...` 和 `[文件]/uploads/...` 路径,避免附件文件名中的时间戳误触发查询;存储与请求仍保留原始附件标记和 URL。
|
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
|
||||||
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。
|
- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
|
||||||
|
- 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。
|
||||||
|
- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。
|
||||||
|
- 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。
|
||||||
|
- 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。
|
||||||
|
- 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
|
||||||
|
- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
|
||||||
|
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
|
||||||
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
|
- 人工申请使用 `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` 访问。
|
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
|
||||||
- 计费档位、单位换算和舍入保持原样。`calc_freight.total` 仅含基础运费;附加费尚未统一并入。知识文档与 `knowledge_list` 同步维护。
|
- 计费档位、单位换算和舍入保持原样。`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,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
- 上传 URL 保留 `/uploads/`,默认 10MB,扩展名配置保持不变。原附件保留本地、继续忽略,目录迁移不另建一份数据。
|
||||||
|
- 手机语音由 `voice.js` 管理按住、松开、上滑取消、60 秒上限及媒体轨道释放;切换会话、离开页面或权限/转写超时取消录音与待处理请求,迟到结果不能进入新会话,已有草稿不自动提交。`/api/speech/config` 仅返回可用性及限制,`/api/speech/transcribe` 有独立 5MB 请求限制并以 `MemoryAudioRequest` 将音频文件保持在内存中;音频不进入 uploads 或聊天历史。百度适配通过 FFmpeg 管道转换 16 kHz 单声道 PCM,校验真实解码长度后调用百度短语音识别,Token 按应用隔离缓存。可选 DashScope 适配和 DS 文字聊天凭据独立,密钥及上游原始错误不得返回客户端。识别结果走既有 AI/人工消息入口。H5 订单详情默认折叠;可视高度变化保持最新回复可见,阅读旧消息时不强制拉到底部。
|
||||||
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
- `agents`、`orders`、`tickets` 在应用初始化时建空表;兼容已有独立 id 列的 agents 表,不预置账号或演示数据。
|
||||||
- 后台鉴权和会话隔离仍不完整,轨迹为模拟数据。Linux 公网验收用 Nginx 全站密码隔离访问,不代表已修复面向客户开放所需的应用权限。
|
- 游客由 `visitor.js` 用安全随机数生成本地 key,经 `/api/visitor/session` 换取 HttpOnly、SameSite Cookie,HTTPS 时设置 Secure;浏览器存储不可用时可用 Cookie 恢复。数据库 `chat_history.visitor_id` 仅存 key 的 SHA-256,历史增删查、SSE 上下文和 `visitor_uploads` 附件索引均按归属校验。旧数据归属为空时保留,不自动授予新访客。客户端公共人工接口为 `/api/visitor/human/{request,poll,send,close}`,校验会话归属、只接受 customer 消息并去掉内部备注;原 `/api/human/*` 留给受保护客服。客服附件用 `/api/staff/uploads/`,所有客户 JSON 与附件禁止共享缓存。
|
||||||
|
- Nginx 默认保护客服及未知路径,只有客户首页、静态资源和明确的客户 API 白名单免密码。后台仍依赖代理认证,上游只能绑定回环地址;客户账号登录独立校验,游客身份不能升级为订单权限。
|
||||||
|
|
||||||
## 验证与维护
|
## 验证与维护
|
||||||
|
|
||||||
|
美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_intent.py` 解析单位、逐箱明细和业务意图;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_answers.py` 和 `quote_fees.py` 从当前版本说明读取业务收费;`quote_language.py` 翻译常规英文报价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。
|
||||||
|
|
||||||
|
新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。
|
||||||
|
|
||||||
|
`quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.py`。
|
||||||
|
|
||||||
|
已确认口径:同件超重/超尺寸只取最高适用档,其他附加费分别计算;洛杉矶普船海卡体积保留两位、四舍五入;海外进口清关超过5个品名的续页按美元/个。原表大尺寸长度条件有误,不猜测下限,只有其他两条明确条件同时满足时自动应用该档,其余疑似大件转人工确认。报价不把提取时效称为签收时效。金额仍读取激活版本,不把私有费率写入源码。
|
||||||
|
|
||||||
|
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
|
||||||
|
|
||||||
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
|
先按 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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
|
现行灰度环境使用 [Jenkins 部署流程](deployment.md)。本页保留独立主机的安装方法,不用于覆盖已有灰度服务。
|
||||||
|
|
||||||
适用场景:Ubuntu / Debian、已有域名、仅自己验收。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx 全站访问密码 → 本机 Waitress → Flask / SQLite。
|
适用场景:Ubuntu / Debian、已有域名。使用系统 Python 3.10+,无需前端构建或 GPU。入口为 HTTPS → Nginx(客户白名单公开,客服及其他路径需密码)→ 本机 Waitress → Flask / SQLite。
|
||||||
|
|
||||||
当前应用尚无完整登录会话、权限和会话数据隔离。验收期间必须保留全站访问密码,不能只保护 `/admin`,因为历史、导出和人工接口也能直接调用。此配置不代表已具备面向客户开放的权限体系。
|
游客历史、人工会话和附件已按访客归属隔离;客户账号登录和订单关联尚未接入,匿名不能查询真实订单。必须沿用示例中的精确公开白名单,保护其余全部路径,包括客服、搜索、导出和原人工接口,不能只保护 `/admin`。上游端口不可对公网开放。
|
||||||
|
|
||||||
## 1. 域名与网络
|
## 1. 域名与网络
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ curl -u preview -I https://chat.example.com/
|
|||||||
|
|
||||||
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
|
- 客户页:`https://chat.example.com/`,发送消息检查 DS 实际回复及流式显示。
|
||||||
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
|
- 验收工作台:`https://chat.example.com/admin/dashboard`,从客户页申请人工,再检查接待和双向消息。
|
||||||
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是整站验收密码,不是 `/admin` 页里的客服账号。
|
- 当前未预置应用内客服登录账号;Nginx 的 `preview` 是客服及受保护接口的访问账号,不是 `/admin` 页里的客服账号,客户首页无需此账号。
|
||||||
|
|
||||||
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
|
安装证书续期后的 Nginx 重载钩子,并检查续期定时器:
|
||||||
|
|
||||||
@@ -151,4 +151,4 @@ sudo nginx -t
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),全站密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
|
工厂调用与监听参数依据 [Waitress 官方文档](https://docs.pylonsproject.org/projects/waitress/en/stable/runner.html)。流式转发配置依据 [Nginx 代理模块](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering),客服后台密码依据 [Nginx Basic Auth 模块](https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html)。证书和续期使用 [Certbot webroot](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot);服务目录与只读保护依据 [systemd.exec](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)。
|
||||||
|
|||||||
+21
-4
@@ -1,8 +1,10 @@
|
|||||||
# Jenkins 灰度部署
|
# Jenkins 灰度部署
|
||||||
|
|
||||||
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS 和全站访问密码。
|
现有灰度环境使用 `Jenkinsfile`、`Dockerfile` 和 `deploy/` 发布独立物流聊天服务。流水线构建并测试镜像,通过严格校验主机指纹的 SSH 发送到专用 Docker Compose 服务,Nginx 提供 HTTPS;客户聊天免登录,客服和其他非白名单路径使用访问密码。
|
||||||
|
|
||||||
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。后台鉴权与会话隔离仍不完整,灰度验收须保留全站密码;此流程不代表已具备面向多个客户开放的权限体系。
|
应用使用单个 Waitress 进程、8 个线程和 UID 10001,宿主机端口仅绑定回环地址。运行密钥、聊天数据、附件和证书不进入镜像。游客历史和附件在应用内按归属隔离;客服仍依赖 Nginx 密码保护,不能裸露上游端口。客户登录独立验证账号及订单范围,匿名查单不会读取订单数据。
|
||||||
|
|
||||||
|
限流和登录失败次数按客户端 IP 计算。Nginx 必须用 `$remote_addr` 覆盖 `X-Forwarded-For`;Waitress 只信任这一项代理头和最后一跳地址,不接受代理头里的 Host、协议或更早的地址。容器镜像在仅由本机 Nginx 接入、宿主端口保持回环绑定的前提下使用 `--trusted-proxy=*`,以兼容 Docker 网关地址;systemd 入口只信任 `127.0.0.1`。直接运行 `am.py` 不接受客户端自报的代理 IP。代理规则参见 [Waitress 文档](https://docs.pylonsproject.org/projects/waitress/en/stable/reverse-proxy.html)。
|
||||||
|
|
||||||
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
容器时区设为 `Asia/Shanghai`,营业时间与聊天时间按中国时间计算。
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@
|
|||||||
| `ENVIRONMENT` | 单选 `gray`,流水线拒绝其他值 |
|
| `ENVIRONMENT` | 单选 `gray`,流水线拒绝其他值 |
|
||||||
| `SOURCE_REF` | 本次发布的不可变标签或提交,也可明确选择开发分支 |
|
| `SOURCE_REF` | 本次发布的不可变标签或提交,也可明确选择开发分支 |
|
||||||
| `PYTHON_BASE_IMAGE` | 已验证的官方 Python 3.12 基础镜像 |
|
| `PYTHON_BASE_IMAGE` | 已验证的官方 Python 3.12 基础镜像 |
|
||||||
|
| `DEBIAN_MIRROR` | 默认 `https://deb.debian.org`;国内可选 `https://mirrors.tuna.tsinghua.edu.cn`,保留 Debian 签名校验 |
|
||||||
| `PUBLIC_ORIGIN` | 独立 HTTPS 域名,不带子路径 |
|
| `PUBLIC_ORIGIN` | 独立 HTTPS 域名,不带子路径 |
|
||||||
| `HOST_PORT` | 已确认空闲的宿主机回环端口,如 `29013` |
|
| `HOST_PORT` | 已确认空闲的宿主机回环端口,如 `29013` |
|
||||||
| `GRAY_DEPLOY_DIR` | 专用目录,末级必须为 `gray-logistics-chat` |
|
| `GRAY_DEPLOY_DIR` | 专用目录,末级必须为 `gray-logistics-chat` |
|
||||||
@@ -29,11 +32,23 @@
|
|||||||
| `SSH_KNOWN_HOSTS_CREDENTIAL_ID` | 已核实的 known_hosts 文件引用 |
|
| `SSH_KNOWN_HOSTS_CREDENTIAL_ID` | 已核实的 known_hosts 文件引用 |
|
||||||
| `DEEPSEEK_CREDENTIAL_ID` | 本服务模型密钥的 Secret text 引用 |
|
| `DEEPSEEK_CREDENTIAL_ID` | 本服务模型密钥的 Secret text 引用 |
|
||||||
| `PREVIEW_AUTH_CREDENTIAL_ID` | 专用验收用户名和密码引用 |
|
| `PREVIEW_AUTH_CREDENTIAL_ID` | 专用验收用户名和密码引用 |
|
||||||
|
| `ASR_PROVIDER` | `baidu` 启用语音;空值关闭 |
|
||||||
|
| `BAIDU_ASR_API_KEY_CREDENTIAL_ID` / `BAIDU_ASR_SECRET_KEY_CREDENTIAL_ID` | 本服务百度语音专用 Secret text 引用 |
|
||||||
|
| `IMPORT_ORDER_SAMPLES` | 默认关闭;明确需要补样本时才启用 |
|
||||||
|
| `IMPORT_QUOTE_DATA` | 默认关闭;本次明确导入通用美国报价才启用 |
|
||||||
|
| `QUOTE_SNAPSHOT_CREDENTIAL_ID` | 默认空;本次私有报价快照的临时 Secret file 引用 |
|
||||||
|
| `ORDER_SNAPSHOT_CREDENTIAL_ID` / `TRACKING_SNAPSHOT_CREDENTIAL_ID` | 本次导入的临时 Secret file 引用 |
|
||||||
|
|
||||||
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
|
运行密钥和验收账号保存在私有灰度配置与 Jenkins 凭据库,供后续发布持续复用;轮换时同步更新。不要将密钥放在构建参数、Git URL、镜像 ARG、日志或示例文件中。
|
||||||
|
|
||||||
|
语音运行配置只注入 `ASR_PROVIDER`、`BAIDU_ASR_API_KEY`、`BAIDU_ASR_SECRET_KEY`,与 DS 密钥一起写入权限 600 的 `runtime.env`。容器使用镜像内的 FFmpeg,不传本机 `FFMPEG_BIN`,也不使用 OCR 凭据。缺少任一百度凭据时拒绝生成发布配置。
|
||||||
|
|
||||||
|
私有订单样本不进入 Git、构建上下文或镜像。可在明确授权后临时上传为 Jenkins Secret file 并启用 `IMPORT_ORDER_SAMPLES`;发布脚本核验快照校验和与实际运行提交,在线备份聊天 SQLite,再调用 `scripts/import_order_test_data.py` 默认导入订单、以 `--tracking-only` 补充轨迹。两步均不使用 `--replace`,保留手工订单字段和已有非空轨迹。临时文件在导入后清除;验收后移除临时 Jenkins 文件凭据并关闭导入开关。此操作只访问聊天服务自己的 SQLite。
|
||||||
|
|
||||||
## 发布步骤
|
## 发布步骤
|
||||||
|
|
||||||
|
报价导入使用 `IMPORT_QUOTE_DATA` 与临时 Secret file,调用 `deploy/import-gray-quotes.sh`:核对快照 SHA 和运行提交、备份 SQLite,再通过生产镜像中的 `scripts/import_quote_data.py --activate` 事务导入。该开关默认关闭,后续普通发布保留已导入数据;导入不修改订单、轨迹或客户范围。原始 Excel 与快照不进入 Git、Docker 上下文或镜像;导入结束清除临时文件,验收后移除临时 Jenkins 文件凭据。新增参数只追加,不改变既有构建参数及其默认值。
|
||||||
|
|
||||||
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
|
1. 完成检查并提交代码,创建 `gray-chat-YYYYMMDD-<commit12>` 一类不可变发布标签。不要重写已有标签或强推覆盖远端分支;分支有其他更新时先明确发布基线。
|
||||||
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
|
2. 推送标签,在任务 `SOURCE_REF` 选择该标签。Jenkins 按此引用加载流水线并检出代码,镜像记录完整提交 SHA。
|
||||||
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
|
3. 流水线检查 Bash 语法,运行 Node 回归,再构建 `test` 目标执行 Python 回归;通过后构建 `production` 应用镜像及 `certbot` 运维镜像。
|
||||||
@@ -58,9 +73,11 @@
|
|||||||
|
|
||||||
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
|
模型密钥写入权限为 600 的 `runtime.env`,由 Compose 注入进程。验收密码只在 Jenkins 生成 Nginx 哈希,服务器保存哈希文件。
|
||||||
|
|
||||||
|
密码哈希位于独立的 `/var/lib/baiyue-logistics-chat-gray-proxy/access.htpasswd`,目录 755、文件 644,供 Nginx 工作进程读取;不放在可能只有 root 可访问的宝塔站点配置目录,也不放明文密码。该目录有本服务所有权标记,不复用未知目录。
|
||||||
|
|
||||||
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
|
更新前执行 SQLite 在线备份。应用启动或 Nginx 检查失败时,仅恢复本服务之前的 Compose 和站点配置,保留数据;不自动用旧库覆盖新记录。失败发布目录保留供定位,数据库降级及删除数据需另行处理。
|
||||||
|
|
||||||
新环境自动创建八张业务表,不创建默认客服账号或演示数据。验收用全站密码直接进入工作台;Nginx 验收账号不是应用内客服账号。
|
新环境自动创建业务表,不创建默认客服账号或演示数据。专用访问密码用于客服工作台及受保护接口;Nginx 账号不是应用内客服账号。客户首页无需密码,客户端先自动建立访客会话,再访问自己的历史。
|
||||||
|
|
||||||
## HTTPS 及续期
|
## HTTPS 及续期
|
||||||
|
|
||||||
@@ -92,7 +109,7 @@ nginx -t
|
|||||||
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
|
- SSH 失败:核对受管凭据和 known_hosts,不禁用指纹检查。
|
||||||
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
|
- 证书失败:核对 DNS 和 HTTP 验证路径,保留本服务的验证站点与私有 ACME 状态。
|
||||||
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
|
- 容器不健康:检查独立目录的 UID 10001 权限、日志和 `/healthz`;健康检查不计入业务限流。
|
||||||
- 入口 401:未登录时为预期,使用专用验收密码后应返回 200。
|
- 客户首页应匿名返回 200,不出现密码弹窗;客服后台、全站搜索和导出匿名返回 401。客户历史 API 在未建立访客会话时由应用返回 JSON 401,自动建立 `/api/visitor/session` 后应返回当前访客的记录。发布激活脚本同时检查这些边界,不能只删除原有密码配置。
|
||||||
- 流式显示延迟:检查实际 Nginx 或 CDN 是否重新启用了响应缓冲。
|
- 流式显示延迟:检查实际 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)。
|
独立主机安装另见 [Linux 手动参考](deployment-linux.md)。凭据与 API 依据 [Jenkins 官方文档](https://www.jenkins.io/doc/pipeline/steps/credentials-binding/) 和 [Remote API](https://www.jenkins.io/doc/book/using/remote-access-api/),证书验证与续期依据 [Certbot 文档](https://eff-certbot.readthedocs.io/en/stable/using.html#webroot)。
|
||||||
|
|||||||
+2
-2
@@ -34,11 +34,11 @@
|
|||||||
|
|
||||||
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
|
### 人工客服、人工、客服、转人工、在线吗、上班、工作时间、营业时间、几点、周六、周日、星期六、星期日、星期天、礼拜、周末、双休、节假日、放假、休息
|
||||||
|
|
||||||
人工客服在线时间:**周一至周五 9:00—18:00**。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。
|
人工客服在线时间:**每天北京时间 8:00—22:00**,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。
|
||||||
|
|
||||||
### 电话、联系方式、怎么联系、联系你们、微信
|
### 电话、联系方式、怎么联系、联系你们、微信
|
||||||
|
|
||||||
可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,21 +5,28 @@ def create_app(config=None):
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from .config import ROOT_DIR, default_config
|
from .config import ROOT_DIR, default_config
|
||||||
from . import db, file_service, hooks
|
from . import customer_service, db, file_service, hooks, visitor_service
|
||||||
from .routes import register_routes
|
from .routes import register_routes
|
||||||
|
from .routes.speech import MemoryAudioRequest
|
||||||
|
|
||||||
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
|
app = Flask(__name__, template_folder=str(ROOT_DIR/'templates'),
|
||||||
static_folder=str(ROOT_DIR/'static'))
|
static_folder=str(ROOT_DIR/'static'))
|
||||||
|
app.request_class = MemoryAudioRequest
|
||||||
app.config.update(default_config())
|
app.config.update(default_config())
|
||||||
if config:
|
if config:
|
||||||
app.config.update(config)
|
app.config.update(config)
|
||||||
app.extensions['human_typing'] = {}
|
app.extensions['human_typing'] = {}
|
||||||
app.extensions['rate_hits'] = defaultdict(list)
|
app.extensions['rate_hits'] = defaultdict(list)
|
||||||
|
app.extensions['customer_login_attempts'] = {}
|
||||||
|
app.extensions['customer_login_lock'] = Lock()
|
||||||
app.before_request(hooks.rate_limit)
|
app.before_request(hooks.rate_limit)
|
||||||
|
app.before_request(visitor_service.attach_visitor)
|
||||||
|
app.before_request(customer_service.attach_customer)
|
||||||
app.after_request(hooks.no_cache_html)
|
app.after_request(hooks.no_cache_html)
|
||||||
app.teardown_appcontext(db.close_connections)
|
app.teardown_appcontext(db.close_connections)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
|
|||||||
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
|
"""回答超长时,让模型压缩到规定字数内;失败则原样返回"""
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
return text
|
return text
|
||||||
|
r = None
|
||||||
try:
|
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}
|
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))
|
r = post_completion(body, timeout=(5, 30))
|
||||||
@@ -39,6 +40,9 @@ def compress_answer(text, limit=get_setting('MAX_ANSWER_CHARS')):
|
|||||||
print(f'[压缩失败] 状态码 {r.status_code}')
|
print(f'[压缩失败] 状态码 {r.status_code}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[压缩异常] {e}')
|
print(f'[压缩异常] {e}')
|
||||||
|
finally:
|
||||||
|
if r is not None:
|
||||||
|
r.close()
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +50,7 @@ def gen_followups(user_msg, answer_text):
|
|||||||
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
|
"""根据本轮问答,生成 3 个客户可能接着问的问题(失败返回空列表,不影响主流程)"""
|
||||||
if not get_setting('DEEPSEEK_API_KEY'):
|
if not get_setting('DEEPSEEK_API_KEY'):
|
||||||
return []
|
return []
|
||||||
|
r = None
|
||||||
try:
|
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}
|
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))
|
r = post_completion(body, timeout=(5, 15))
|
||||||
@@ -58,4 +63,7 @@ def gen_followups(user_msg, answer_text):
|
|||||||
return [str(x).strip() for x in arr if str(x).strip()][:3]
|
return [str(x).strip() for x in arr if str(x).strip()][:3]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
finally:
|
||||||
|
if r is not None:
|
||||||
|
r.close()
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -8,8 +8,17 @@ from .tracking import card_to_text
|
|||||||
|
|
||||||
def feedback(data):
|
def feedback(data):
|
||||||
"""收集 👍/👎 反馈,存到 feedback 表"""
|
"""收集 👍/👎 反馈,存到 feedback 表"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok': False, 'error': '评价格式无效。'}, 400
|
||||||
|
raw_vote = data.get('vote')
|
||||||
|
votes = {'up': 1, 'down': -1, '1': 1, '-1': -1}
|
||||||
|
vote = votes.get(raw_vote) if isinstance(raw_vote, str) else raw_vote
|
||||||
|
question, answer = data.get('question', ''), data.get('answer', '')
|
||||||
|
if type(vote) is not int or vote not in (-1, 1) or not isinstance(question, str) or not isinstance(answer, str):
|
||||||
|
return {'ok': False, 'error': '评价内容无效,请重试。'}, 400
|
||||||
conn = connect()
|
conn = connect()
|
||||||
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)', (str(data.get('question', ''))[:500], str(data.get('answer', ''))[:2000], str(data.get('vote', '')), now_str()))
|
conn.execute('INSERT INTO feedback(question, answer, vote, ctime) VALUES (?,?,?,?)',
|
||||||
|
(question[:500], answer[:2000], vote, now_str()))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
@@ -19,8 +28,8 @@ def admin_stats():
|
|||||||
conn = connect()
|
conn = connect()
|
||||||
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
|
total_conv = conn.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0]
|
||||||
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
|
total_msg = conn.execute('SELECT COUNT(*) FROM messages').fetchone()[0]
|
||||||
good = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=1').fetchone()[0]
|
good = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('1', 'up')").fetchone()[0]
|
||||||
bad = conn.execute('SELECT COUNT(*) FROM feedback WHERE vote=-1').fetchone()[0]
|
bad = conn.execute("SELECT COUNT(*) FROM feedback WHERE vote IN ('-1', 'down')").fetchone()[0]
|
||||||
today = datetime.now().strftime('%Y-%m-%d')
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
today_conv = conn.execute('SELECT COUNT(*) FROM chat_history WHERE date(ctime)=date(?)', (today,)).fetchone()[0]
|
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()
|
rating_avg, rating_count = conn.execute('SELECT COALESCE(AVG(stars), 0), COUNT(*) FROM ratings').fetchone()
|
||||||
@@ -39,8 +48,16 @@ def my_orders(phone):
|
|||||||
|
|
||||||
|
|
||||||
def add_ticket(data):
|
def add_ticket(data):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok': False, 'error': '留言格式无效。'}, 400
|
||||||
|
content, phone = data.get('content'), data.get('phone', '')
|
||||||
|
if not isinstance(content, str) or not 1 <= len(content.strip()) <= 2000:
|
||||||
|
return {'ok': False, 'error': '请输入 1–2000 字的留言。'}, 400
|
||||||
|
if not isinstance(phone, str) or len(phone.strip()) > 32:
|
||||||
|
return {'ok': False, 'error': '联系电话格式无效。'}, 400
|
||||||
conn = connect()
|
conn = connect()
|
||||||
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)', (data.get('content', ''), data.get('phone', ''), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
conn.execute('INSERT INTO tickets(content, phone, ctime, status) VALUES(?,?,?,?)',
|
||||||
|
(content.strip(), phone.strip(), datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'pending'))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|||||||
+119
-27
@@ -9,8 +9,11 @@ from .config import get_setting
|
|||||||
from .db import connect, last_user_question, load_context, save_message
|
from .db import connect, last_user_question, load_context, save_message
|
||||||
from .knowledge import search_knowledge
|
from .knowledge import search_knowledge
|
||||||
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
|
from .pricing import (parse_shipping_intent, detect_dim_unit, freight_reply,
|
||||||
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island)
|
calc_warehouse_card, detect_surcharge, surcharge_reply, check_island,
|
||||||
from .tracking import CARD_PREFIX, extract_tracking_no, build_track
|
is_shipping_destination_reply)
|
||||||
|
from .tracking import CARD_PREFIX
|
||||||
|
from .order_data import answer_query, extract_reference
|
||||||
|
from .quote_service import answer_quote
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -20,45 +23,119 @@ def sse(payload):
|
|||||||
return 'data: ' + json.dumps(payload, ensure_ascii=False) + '\n\n'
|
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):
|
def create_chat_stream(msg, conv_id):
|
||||||
if not msg:
|
if not msg:
|
||||||
return (sse({'e': '问题不能为空'}), {})
|
return (sse({'e': '问题不能为空'}), {})
|
||||||
if '[图片]' in msg and len(msg) < 300:
|
if conv_id:
|
||||||
return (sse({'t': '收到您的图片,请描述一下您的问题。'}) + sse({'d': 1}), {})
|
from .visitor_service import owns_conversation
|
||||||
track_no = extract_tracking_no(re.sub(r'\[(?:图片|文件)\]/uploads/\S+', '', msg))
|
if not owns_conversation(conv_id):
|
||||||
if track_no:
|
from .customer_service import needs_customer_login, order_login_response
|
||||||
card = build_track(track_no)
|
if needs_customer_login(conv_id):
|
||||||
|
return (sse(order_login_response()) + sse({'d':1}), {})
|
||||||
|
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
|
||||||
|
order_answer = answer_query(msg, conv_id)
|
||||||
|
if order_answer is None:
|
||||||
|
order_answer = answer_quote(msg, conv_id)
|
||||||
|
if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg):
|
||||||
|
order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'}
|
||||||
|
if order_answer is None and re.search(r'照片|图片',msg) and re.search(r'报价|价格|判断|识别',msg):
|
||||||
|
order_answer = {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
|
||||||
|
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:
|
||||||
|
customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer
|
||||||
|
|
||||||
def track_stream():
|
def track_stream():
|
||||||
|
if customer_data:
|
||||||
|
from .customer_service import bind_order_conversation, current_customer_id
|
||||||
|
if current_customer_id() and not bind_order_conversation(conv_id):
|
||||||
|
yield sse({'e':'该会话暂时无法访问,请新建对话后重试。','d':1})
|
||||||
|
return
|
||||||
save_message(conv_id, 'user', msg)
|
save_message(conv_id, 'user', msg)
|
||||||
yield sse({'card': card})
|
content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False)
|
||||||
save_message(conv_id, 'assistant', CARD_PREFIX + json.dumps(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})
|
yield sse({'d': 1})
|
||||||
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||||
if not get_setting('DEEPSEEK_API_KEY'):
|
if not get_setting('DEEPSEEK_API_KEY'):
|
||||||
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
|
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
|
||||||
know_text = search_knowledge(msg)
|
know_text = search_knowledge(msg)
|
||||||
ship = parse_shipping_intent(msg)
|
|
||||||
ship['dim_unit'] = detect_dim_unit(msg)
|
|
||||||
freight_text = None
|
freight_text = None
|
||||||
if not ship['weight'] and (not ship['dims']) and ship['channel']:
|
|
||||||
prev_q = last_user_question(conv_id)
|
|
||||||
if prev_q:
|
|
||||||
prev_ship = parse_shipping_intent(prev_q)
|
|
||||||
if prev_ship['weight'] or prev_ship['dims']:
|
|
||||||
ship['weight'] = prev_ship['weight']
|
|
||||||
ship['dims'] = prev_ship['dims']
|
|
||||||
ship['dim_unit'] = detect_dim_unit(prev_q)
|
|
||||||
if ship['weight'] or ship['dims']:
|
if ship['weight'] or ship['dims']:
|
||||||
if re.search('英国|UK|伦敦|英格兰', msg, re.I):
|
|
||||||
uk_map = {'sea': 'sea_uk', 'rail': 'rail_uk', 'truck': 'truck_uk', 'air': 'air_uk'}
|
|
||||||
ship['channel'] = uk_map.get(ship['channel'])
|
|
||||||
freight_text = freight_reply(ship)
|
freight_text = freight_reply(ship)
|
||||||
if not ship['channel']:
|
if not ship['channel']:
|
||||||
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
|
freight_text += '\n\n(客户没有指定渠道,上面已同时给出海运和空运,请在回答末尾询问更看重时效还是成本)'
|
||||||
extra_parts = []
|
extra_parts = []
|
||||||
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
|
cbm_m = re.search('(\\d+(?:\\.\\d+)?)\\s*(?:cbm|方|立方)', msg, re.I)
|
||||||
if cbm_m:
|
if cbm_m and ship['region'] != 'uk':
|
||||||
cbm = float(cbm_m.group(1))
|
cbm = float(cbm_m.group(1))
|
||||||
ch = '海运'
|
ch = '海运'
|
||||||
if re.search('卡航|卡车', msg):
|
if re.search('卡航|卡车', msg):
|
||||||
@@ -144,8 +221,11 @@ def create_chat_stream(msg, conv_id):
|
|||||||
return
|
return
|
||||||
parts = []
|
parts = []
|
||||||
interrupted = False
|
interrupted = False
|
||||||
|
answer_saved = False
|
||||||
try:
|
try:
|
||||||
got_text = False
|
got_text = False
|
||||||
|
completed = False
|
||||||
|
finish_reason = None
|
||||||
for raw in resp.iter_lines():
|
for raw in resp.iter_lines():
|
||||||
if not raw:
|
if not raw:
|
||||||
continue
|
continue
|
||||||
@@ -154,10 +234,15 @@ def create_chat_stream(msg, conv_id):
|
|||||||
continue
|
continue
|
||||||
data = line[5:].strip()
|
data = line[5:].strip()
|
||||||
if data == '[DONE]':
|
if data == '[DONE]':
|
||||||
|
completed = True
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
j = json.loads(data)
|
j = json.loads(data)
|
||||||
chunk = j['choices'][0]['delta'].get('content') or ''
|
choice = j['choices'][0]
|
||||||
|
chunk = choice.get('delta', {}).get('content') or ''
|
||||||
|
finish_reason = choice.get('finish_reason') or finish_reason
|
||||||
|
if finish_reason == 'stop':
|
||||||
|
completed = True
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if chunk:
|
if chunk:
|
||||||
@@ -167,6 +252,10 @@ def create_chat_stream(msg, conv_id):
|
|||||||
if not got_text:
|
if not got_text:
|
||||||
yield sse({'e': '模型没有返回内容,请重试'})
|
yield sse({'e': '模型没有返回内容,请重试'})
|
||||||
return
|
return
|
||||||
|
if not completed or finish_reason not in (None, 'stop'):
|
||||||
|
interrupted = True
|
||||||
|
yield sse({'e': '回答未生成完整,请点击重试'})
|
||||||
|
return
|
||||||
full_text = ''.join(parts)
|
full_text = ''.join(parts)
|
||||||
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
|
limit = get_setting('MAX_ANSWER_CHARS') if has_chinese(full_text) else get_setting('MAX_ANSWER_CHARS_EN')
|
||||||
if len(full_text) > limit:
|
if len(full_text) > limit:
|
||||||
@@ -177,6 +266,7 @@ def create_chat_stream(msg, conv_id):
|
|||||||
full_text = short
|
full_text = short
|
||||||
yield sse({'replace': short})
|
yield sse({'replace': short})
|
||||||
save_message(conv_id, 'assistant', full_text)
|
save_message(conv_id, 'assistant', full_text)
|
||||||
|
answer_saved = True
|
||||||
if is_first_turn:
|
if is_first_turn:
|
||||||
try:
|
try:
|
||||||
fu = gen_followups(msg, full_text)
|
fu = gen_followups(msg, full_text)
|
||||||
@@ -193,7 +283,9 @@ def create_chat_stream(msg, conv_id):
|
|||||||
yield sse({'e': '传输中断,请点击重试'})
|
yield sse({'e': '传输中断,请点击重试'})
|
||||||
finally:
|
finally:
|
||||||
text = ''.join(parts)
|
text = ''.join(parts)
|
||||||
if interrupted and len(text) >= 20:
|
try:
|
||||||
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
|
if interrupted and text and not answer_saved:
|
||||||
resp.close()
|
save_message(conv_id, 'assistant', text + '\n\n(回答中断,以上内容未生成完整)')
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
return (generate(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ def default_config():
|
|||||||
'BACKUP_DIR': ROOT_DIR / 'backups',
|
'BACKUP_DIR': ROOT_DIR / 'backups',
|
||||||
'LOG_DIR': ROOT_DIR / 'logs',
|
'LOG_DIR': ROOT_DIR / 'logs',
|
||||||
'DEEPSEEK_API_KEY': (api_key or '').strip(),
|
'DEEPSEEK_API_KEY': (api_key or '').strip(),
|
||||||
|
'ASR_API_KEY': str(os.environ.get('ASR_API_KEY', local_config.get('ASR_API_KEY')) or '').strip(),
|
||||||
|
'ASR_PROVIDER': str(os.environ.get('ASR_PROVIDER', local_config.get('ASR_PROVIDER')) or 'baidu').strip().lower(),
|
||||||
|
'BAIDU_ASR_API_KEY': str(os.environ.get('BAIDU_ASR_API_KEY', local_config.get('BAIDU_ASR_API_KEY')) or '').strip(),
|
||||||
|
'BAIDU_ASR_SECRET_KEY': str(os.environ.get('BAIDU_ASR_SECRET_KEY', local_config.get('BAIDU_ASR_SECRET_KEY')) or '').strip(),
|
||||||
|
'FFMPEG_BIN': str(os.environ.get('FFMPEG_BIN', local_config.get('FFMPEG_BIN')) or '').strip(),
|
||||||
|
'ASR_URL': str(os.environ.get('ASR_URL', local_config.get('ASR_URL')) or
|
||||||
|
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').strip(),
|
||||||
|
'ASR_MODEL': str(os.environ.get('ASR_MODEL', local_config.get('ASR_MODEL')) or 'qwen3-asr-flash').strip(),
|
||||||
|
'ORDER_DATA_TENANT_ID': str(os.environ.get('ORDER_DATA_TENANT_ID', local_config.get('ORDER_DATA_TENANT_ID')) or '').strip(),
|
||||||
|
'CUSTOMER_DEMO_ENABLED': str(os.environ.get('CUSTOMER_DEMO_ENABLED', local_config.get('CUSTOMER_DEMO_ENABLED', 'true'))).lower() in ('1','true','yes'),
|
||||||
|
'CUSTOMER_DEMO_USERNAME': str(os.environ.get('CUSTOMER_DEMO_USERNAME', local_config.get('CUSTOMER_DEMO_USERNAME', 'admin')) or '').strip(),
|
||||||
|
'CUSTOMER_DEMO_PASSWORD_HASH': str(os.environ.get('CUSTOMER_DEMO_PASSWORD_HASH', local_config.get('CUSTOMER_DEMO_PASSWORD_HASH',
|
||||||
|
'pbkdf2:sha256:600000$vY5fe7GwJQRQbhKV$a4c4eeb96e2bda18bfa6b82d263c0cd61dead2e3f224f694e01fc497152df72f')) or ''),
|
||||||
|
'CUSTOMER_DEMO_TENANT_ID': str(os.environ.get('CUSTOMER_DEMO_TENANT_ID', local_config.get('CUSTOMER_DEMO_TENANT_ID')) or '').strip(),
|
||||||
|
'CUSTOMER_DEMO_LOCAL_ORDERS': str(os.environ.get('CUSTOMER_DEMO_LOCAL_ORDERS', local_config.get('CUSTOMER_DEMO_LOCAL_ORDERS', 'true'))).lower() in ('1','true','yes'),
|
||||||
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
|
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
|
||||||
'MODEL_NAME': 'deepseek-chat',
|
'MODEL_NAME': 'deepseek-chat',
|
||||||
'MAX_CONTEXT': 4,
|
'MAX_CONTEXT': 4,
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Customer authentication and order scope, independent of staff authentication."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import g, has_app_context, jsonify, request
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
from .db import connect
|
||||||
|
from .visitor_service import valid_key, visitor_id
|
||||||
|
|
||||||
|
COOKIE_NAME = 'baiyue_customer'
|
||||||
|
SESSION_SECONDS = 8 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def demo_tenant(connection):
|
||||||
|
configured = get_setting('CUSTOMER_DEMO_TENANT_ID') or get_setting('ORDER_DATA_TENANT_ID')
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
rows = connection.execute("SELECT DISTINCT tenant_id FROM order_test_data WHERE tenant_id<>'' LIMIT 2").fetchall()
|
||||||
|
return rows[0][0] if len(rows) == 1 else ''
|
||||||
|
|
||||||
|
|
||||||
|
def user_tenants(connection, row):
|
||||||
|
if row[4]:
|
||||||
|
return (row[4],)
|
||||||
|
rows = connection.execute('SELECT tenant_id FROM customer_tenant_grants WHERE customer_id=? ORDER BY tenant_id LIMIT 257',
|
||||||
|
(row[0],)).fetchall()
|
||||||
|
return tuple(item[0] for item in rows) if len(rows) <= 256 else ()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_local_grants(connection, row):
|
||||||
|
if not row[6] or connection.execute('SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||||
|
return
|
||||||
|
if not row[4] and get_setting('CUSTOMER_DEMO_LOCAL_ORDERS') and not user_tenants(connection,row):
|
||||||
|
connection.execute('INSERT OR IGNORE INTO customer_tenant_grants(customer_id,tenant_id) '
|
||||||
|
"SELECT ?,tenant_id FROM order_test_data WHERE tenant_id<>'' GROUP BY tenant_id",(row[0],))
|
||||||
|
if user_tenants(connection,row):
|
||||||
|
connection.execute('UPDATE customer_users SET scope_initialized=1 WHERE id=?',(row[0],))
|
||||||
|
|
||||||
|
|
||||||
|
def scope_key(tenants):
|
||||||
|
return hashlib.sha256(json.dumps(sorted(tenants),ensure_ascii=False).encode()).hexdigest() if tenants else None
|
||||||
|
|
||||||
|
|
||||||
|
def effective_tenants(tenants):
|
||||||
|
configured = get_setting('ORDER_DATA_TENANT_ID')
|
||||||
|
return tuple(value for value in tenants if not configured or value == configured)
|
||||||
|
|
||||||
|
|
||||||
|
def current_scope_key():
|
||||||
|
from .visitor_service import customer_tenant_ids
|
||||||
|
return scope_key(customer_tenant_ids())
|
||||||
|
|
||||||
|
|
||||||
|
def init_customer_tables(connection):
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS customer_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, display_name TEXT NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_demo INTEGER NOT NULL DEFAULT 0, scope_initialized INTEGER NOT NULL DEFAULT 0)''')
|
||||||
|
if 'scope_initialized' not in [item[1] for item in connection.execute('PRAGMA table_info(customer_users)')]:
|
||||||
|
connection.execute('ALTER TABLE customer_users ADD COLUMN scope_initialized INTEGER NOT NULL DEFAULT 0')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS customer_sessions (
|
||||||
|
token_hash TEXT PRIMARY KEY, customer_id INTEGER NOT NULL,
|
||||||
|
visitor_id TEXT NOT NULL, auth_stamp TEXT NOT NULL, expires_at INTEGER NOT NULL)''')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_customer_session_expiry ON customer_sessions(expires_at)')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS customer_tenant_grants (
|
||||||
|
customer_id INTEGER NOT NULL, tenant_id TEXT NOT NULL, PRIMARY KEY(customer_id,tenant_id))''')
|
||||||
|
if get_setting('CUSTOMER_DEMO_ENABLED'):
|
||||||
|
connection.execute('''INSERT OR IGNORE INTO customer_users
|
||||||
|
(username,password_hash,display_name,tenant_id,is_demo) VALUES(?,?,?,?,1)''',
|
||||||
|
(get_setting('CUSTOMER_DEMO_USERNAME'), get_setting('CUSTOMER_DEMO_PASSWORD_HASH'),
|
||||||
|
get_setting('CUSTOMER_DEMO_USERNAME'), demo_tenant(connection)))
|
||||||
|
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||||
|
'FROM customer_users WHERE username=?',(get_setting('CUSTOMER_DEMO_USERNAME'),)).fetchone()
|
||||||
|
seed_local_grants(connection,row)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_stamp(row, tenants):
|
||||||
|
return hashlib.sha256((row[2] + '\n' + (scope_key(effective_tenants(tenants)) or '')).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_user(row):
|
||||||
|
return bool(row and row[5] and (not row[6] or (
|
||||||
|
get_setting('CUSTOMER_DEMO_ENABLED') and row[1] == get_setting('CUSTOMER_DEMO_USERNAME'))))
|
||||||
|
|
||||||
|
|
||||||
|
def attach_customer():
|
||||||
|
g.customer = None
|
||||||
|
g.customer_tenant_id = None
|
||||||
|
g.customer_tenant_ids = ()
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not visitor_id() or not valid_key(token):
|
||||||
|
return
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
session = connection.execute('SELECT customer_id,auth_stamp FROM customer_sessions '
|
||||||
|
'WHERE token_hash=? AND visitor_id=? AND expires_at>?',
|
||||||
|
(hashlib.sha256(token.encode()).hexdigest(), visitor_id(), int(time.time()))).fetchone()
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||||
|
'FROM customer_users WHERE id=?', (session[0],)).fetchone()
|
||||||
|
tenants = effective_tenants(user_tenants(connection,row)) if row else ()
|
||||||
|
if allowed_user(row) and secrets.compare_digest(auth_stamp(row,tenants), session[1]):
|
||||||
|
g.customer = {'id':row[0], 'username':row[1], 'name':row[3]}
|
||||||
|
g.customer_tenant_id = tenants[0] if len(tenants) == 1 else None
|
||||||
|
g.customer_tenant_ids = tenants
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def current_customer_id():
|
||||||
|
customer = getattr(g, 'customer', None) if has_app_context() else None
|
||||||
|
return customer['id'] if customer else None
|
||||||
|
|
||||||
|
|
||||||
|
def bind_order_conversation(conv_id):
|
||||||
|
"""A conversation containing order facts also requires its customer login."""
|
||||||
|
if not conv_id:
|
||||||
|
return True
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
result = connection.execute('UPDATE chat_history SET customer_id=?,customer_scope=? WHERE id=? AND visitor_id=? '
|
||||||
|
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||||
|
(current_customer_id(),current_scope_key(),conv_id,visitor_id(),current_customer_id(),current_scope_key()))
|
||||||
|
connection.commit()
|
||||||
|
return result.rowcount == 1
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def session_status():
|
||||||
|
return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer}
|
||||||
|
|
||||||
|
|
||||||
|
def order_login_response():
|
||||||
|
if current_customer_id():
|
||||||
|
return {'t':'当前账号尚未关联订单,请联系人工客服。'}
|
||||||
|
return {'t':'请先使用客户账号登录并关联订单,再查询订单或物流轨迹。','login_required':True}
|
||||||
|
|
||||||
|
|
||||||
|
def needs_customer_login(conv_id):
|
||||||
|
if current_customer_id():
|
||||||
|
return False
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||||
|
'AND customer_id IS NOT NULL',(conv_id,visitor_id())).fetchone())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def login(data, attempts, lock):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok':False, 'error':'请输入账号和密码。'}, 400
|
||||||
|
username, password = data.get('username'), data.get('password')
|
||||||
|
if not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 80 or not 1 <= len(password) <= 256:
|
||||||
|
return {'ok':False, 'error':'请输入有效的账号和密码。'}, 400
|
||||||
|
now = int(time.time())
|
||||||
|
# A separate short failed-login budget supplements the global request limiter.
|
||||||
|
ip = request.remote_addr or 'unknown'
|
||||||
|
with lock:
|
||||||
|
failures = [stamp for stamp in attempts.get(ip, []) if stamp > now - 300]
|
||||||
|
if len(attempts) > 2048:
|
||||||
|
for key in list(attempts):
|
||||||
|
if not attempts[key] or attempts[key][-1] <= now - 300:
|
||||||
|
attempts.pop(key,None)
|
||||||
|
if len(failures) >= 5:
|
||||||
|
return {'ok':False, 'error':'登录尝试过多,请稍后重试。'}, 429
|
||||||
|
attempts[ip] = failures + [now]
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||||
|
'FROM customer_users WHERE username=?', (username.strip(),)).fetchone()
|
||||||
|
digest = row[2] if row else get_setting('CUSTOMER_DEMO_PASSWORD_HASH')
|
||||||
|
try:
|
||||||
|
valid = check_password_hash(digest, password)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
valid = False
|
||||||
|
if not valid or not allowed_user(row):
|
||||||
|
return {'ok':False, 'error':'账号或密码不正确。'}, 403
|
||||||
|
with lock:
|
||||||
|
attempts.pop(ip, None)
|
||||||
|
if row[6] and not row[4] and not user_tenants(connection,row) and not connection.execute(
|
||||||
|
'SELECT scope_initialized FROM customer_users WHERE id=?',(row[0],)).fetchone()[0]:
|
||||||
|
tenant = demo_tenant(connection)
|
||||||
|
if tenant:
|
||||||
|
connection.execute("UPDATE customer_users SET tenant_id=? WHERE id=? AND tenant_id=''", (tenant,row[0]))
|
||||||
|
row = connection.execute('SELECT id,username,password_hash,display_name,tenant_id,enabled,is_demo '
|
||||||
|
'FROM customer_users WHERE id=?', (row[0],)).fetchone()
|
||||||
|
seed_local_grants(connection,row)
|
||||||
|
old_token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if valid_key(old_token):
|
||||||
|
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||||
|
(hashlib.sha256(old_token.encode()).hexdigest(), visitor_id()))
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
connection.execute('DELETE FROM customer_sessions WHERE expires_at<=?', (now,))
|
||||||
|
connection.execute('INSERT INTO customer_sessions VALUES(?,?,?,?,?)',
|
||||||
|
(hashlib.sha256(token.encode()).hexdigest(),row[0],visitor_id(),auth_stamp(row,user_tenants(connection,row)),now+SESSION_SECONDS))
|
||||||
|
connection.commit()
|
||||||
|
response = jsonify({'ok':True,'authenticated':True,'user':{'id':row[0],'username':row[1],'name':row[3]}})
|
||||||
|
response.set_cookie(COOKIE_NAME,token,max_age=SESSION_SECONDS,httponly=True,secure=request.is_secure,samesite='Lax',path='/')
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def logout():
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if valid_key(token):
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
connection.execute('DELETE FROM customer_sessions WHERE token_hash=? AND visitor_id=?',
|
||||||
|
(hashlib.sha256(token.encode()).hexdigest(),visitor_id()))
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
response = jsonify({'ok':True,'authenticated':False,'user':None})
|
||||||
|
response.delete_cookie(COOKIE_NAME,path='/',secure=request.is_secure,httponly=True,samesite='Lax')
|
||||||
|
return response
|
||||||
+31
-1
@@ -37,6 +37,8 @@ def init_db():
|
|||||||
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
|
Path(get_setting('DATABASE')).parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = connect()
|
conn = connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Serialize schema inspection and migration when multiple workers start together.
|
||||||
|
cur.execute('BEGIN IMMEDIATE')
|
||||||
cur.execute('''
|
cur.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS chat_history (
|
CREATE TABLE IF NOT EXISTS chat_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -48,6 +50,23 @@ def init_db():
|
|||||||
if 'utime' not in cols:
|
if 'utime' not in cols:
|
||||||
cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT')
|
cur.execute('ALTER TABLE chat_history ADD COLUMN utime TEXT')
|
||||||
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
cur.execute('UPDATE chat_history SET utime = ctime WHERE utime IS NULL')
|
||||||
|
if 'visitor_id' not in cols:
|
||||||
|
cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT')
|
||||||
|
if 'customer_id' not in cols:
|
||||||
|
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER')
|
||||||
|
if 'customer_scope' not in cols:
|
||||||
|
cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT')
|
||||||
|
if 'customer_tenant_id' in cols:
|
||||||
|
# Preserve conversations created by the earlier local single-tenant login.
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
for cid, tenant in cur.execute('SELECT id,customer_tenant_id FROM chat_history WHERE customer_id IS NOT NULL').fetchall():
|
||||||
|
scope = hashlib.sha256(json.dumps([tenant],ensure_ascii=False).encode()).hexdigest() if tenant else None
|
||||||
|
cur.execute('UPDATE chat_history SET customer_scope=? WHERE id=?',(scope,cid))
|
||||||
|
cur.execute('CREATE INDEX IF NOT EXISTS idx_chat_visitor ON chat_history(visitor_id, id)')
|
||||||
|
cur.execute('''CREATE TABLE IF NOT EXISTS visitor_uploads (
|
||||||
|
filename TEXT PRIMARY KEY, visitor_id TEXT NOT NULL, ctime TEXT NOT NULL
|
||||||
|
)''')
|
||||||
cur.execute('''
|
cur.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -126,6 +145,12 @@ def init_db():
|
|||||||
status TEXT
|
status TEXT
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
from .order_data import init_order_table
|
||||||
|
init_order_table(conn)
|
||||||
|
from .customer_service import init_customer_tables
|
||||||
|
init_customer_tables(conn)
|
||||||
|
from .quote_data import init_quote_tables
|
||||||
|
init_quote_tables(conn)
|
||||||
old = cur.execute('SELECT id, title FROM chat_history WHERE id NOT IN (SELECT DISTINCT conv_id FROM messages)').fetchall()
|
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:
|
for cid, title in old:
|
||||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
|
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (cid, 'user', title, now_str()))
|
||||||
@@ -201,7 +226,12 @@ def save_message(conv_id, role, content):
|
|||||||
if last and last[0] == 'user' and (last[1] == content):
|
if last and last[0] == 'user' and (last[1] == content):
|
||||||
conn.close()
|
conn.close()
|
||||||
return 'duplicate'
|
return 'duplicate'
|
||||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)', (conv_id, role, content, now_str()))
|
inserted = cur.execute('INSERT INTO messages(conv_id, role, content, ctime) '
|
||||||
|
'SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM chat_history WHERE id=?)',
|
||||||
|
(conv_id, role, content, now_str(), conv_id)).rowcount
|
||||||
|
if not inserted:
|
||||||
|
conn.close()
|
||||||
|
return 'deleted'
|
||||||
if role == 'user':
|
if role == 'user':
|
||||||
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone()
|
||||||
if row and (not row[0] or row[0] == '新对话'):
|
if row and (not row[0] or row[0] == '新对话'):
|
||||||
|
|||||||
@@ -1,22 +1,36 @@
|
|||||||
"""Attachment validation, storage and retention without request parsing."""
|
"""Attachment validation, storage and retention without request parsing."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import secrets
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from .config import get_setting
|
from .config import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_uploads():
|
def cleanup_uploads():
|
||||||
"""删除 uploads/ 里 7 天前的图片"""
|
"""Keep attachments referenced by chat history; expire unused uploads after 7 days."""
|
||||||
try:
|
try:
|
||||||
udir = get_setting('UPLOAD_DIR')
|
udir = get_setting('UPLOAD_DIR')
|
||||||
if not os.path.isdir(udir):
|
if not os.path.isdir(udir):
|
||||||
return
|
return
|
||||||
|
from .db import connect
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
referenced = set()
|
||||||
|
rows = connection.execute("SELECT content FROM messages WHERE content LIKE '%/uploads/%' "
|
||||||
|
"UNION ALL SELECT content FROM human_messages WHERE content LIKE '%/uploads/%'")
|
||||||
|
for (content,) in rows:
|
||||||
|
referenced.update(unquote(name) for name in re.findall(r'/uploads/([A-Za-z0-9_.%-]+)', content or ''))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
cutoff = time.time() - 7 * 86400
|
cutoff = time.time() - 7 * 86400
|
||||||
for f in os.listdir(udir):
|
for f in os.listdir(udir):
|
||||||
|
if f in referenced:
|
||||||
|
continue
|
||||||
fp = os.path.join(udir, f)
|
fp = os.path.join(udir, f)
|
||||||
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
|
if os.path.isfile(fp) and os.path.getmtime(fp) < cutoff:
|
||||||
os.remove(fp)
|
os.remove(fp)
|
||||||
@@ -40,7 +54,18 @@ def upload_file(files):
|
|||||||
if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024:
|
if size > get_setting('MAX_UPLOAD_MB') * 1024 * 1024:
|
||||||
return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400)
|
return ({'ok': False, 'error': f"文件超过 {get_setting('MAX_UPLOAD_MB')}MB"}, 400)
|
||||||
os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True)
|
os.makedirs(get_setting('UPLOAD_DIR'), exist_ok=True)
|
||||||
safe_name = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{random.randint(1000, 9999)}{ext}"
|
safe_name = secrets.token_hex(16) + ext
|
||||||
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
|
f.save(os.path.join(get_setting('UPLOAD_DIR'), safe_name))
|
||||||
|
from .db import connect, now_str
|
||||||
|
from .visitor_service import visitor_id
|
||||||
|
owner = visitor_id()
|
||||||
|
if owner:
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
connection.execute('INSERT INTO visitor_uploads(filename,visitor_id,ctime) VALUES(?,?,?)',
|
||||||
|
(safe_name, owner, now_str()))
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
|
is_image = ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp')
|
||||||
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
|
return {'ok': True, 'name': f.filename, 'url': f'/uploads/{safe_name}', 'is_image': is_image}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""Conversation CRUD with unchanged response data."""
|
"""Conversation CRUD with unchanged response data."""
|
||||||
|
|
||||||
from .db import connect, now_str
|
from .db import connect, now_str
|
||||||
|
from .visitor_service import visitor_id, owns_conversation
|
||||||
|
from .customer_service import current_customer_id, current_scope_key
|
||||||
|
|
||||||
|
|
||||||
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。'
|
WELCOME_TEXT = '您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。'
|
||||||
|
|
||||||
|
|
||||||
def new_chat():
|
def new_chat():
|
||||||
now = now_str()
|
now = now_str()
|
||||||
conn = connect()
|
conn = connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute('INSERT INTO chat_history(title, ctime, utime) VALUES (?,?,?)', ('新对话', now, now))
|
cur.execute('INSERT INTO chat_history(title, ctime, utime, visitor_id) VALUES (?,?,?,?)',
|
||||||
|
('新对话', now, now, visitor_id()))
|
||||||
conv_id = cur.lastrowid
|
conv_id = cur.lastrowid
|
||||||
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
|
cur.execute('INSERT INTO messages(conv_id, role, content, ctime) VALUES (?,?,?,?)',
|
||||||
(conv_id, 'assistant', WELCOME_TEXT, now))
|
(conv_id, 'assistant', WELCOME_TEXT, now))
|
||||||
@@ -21,12 +24,16 @@ def new_chat():
|
|||||||
|
|
||||||
def get_history():
|
def get_history():
|
||||||
conn = connect()
|
conn = connect()
|
||||||
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history ORDER BY COALESCE(utime, ctime) DESC, id DESC').fetchall()
|
rows = conn.execute('SELECT id, title, ctime, COALESCE(utime, ctime) FROM chat_history WHERE visitor_id=? '
|
||||||
|
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?)) '
|
||||||
|
'ORDER BY COALESCE(utime, ctime) DESC, id DESC', (visitor_id(),current_customer_id(),current_scope_key())).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
return [{'id': r[0], 'title': r[1], 'ctime': r[2], 'utime': r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get_messages(conv_id):
|
def get_messages(conv_id):
|
||||||
|
if not owns_conversation(conv_id):
|
||||||
|
return []
|
||||||
conn = connect()
|
conn = connect()
|
||||||
rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall()
|
rows = conn.execute('SELECT role, content, ctime FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 100', (conv_id,)).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -35,8 +42,12 @@ def get_messages(conv_id):
|
|||||||
|
|
||||||
|
|
||||||
def del_history(hid):
|
def del_history(hid):
|
||||||
|
if not owns_conversation(hid):
|
||||||
|
return {'ok': False}, 404
|
||||||
conn = connect()
|
conn = connect()
|
||||||
cur = conn.cursor()
|
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 messages WHERE conv_id=?', (hid,))
|
||||||
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
|
cur.execute('DELETE FROM chat_history WHERE id=?', (hid,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -47,8 +58,14 @@ def del_history(hid):
|
|||||||
def clear_all():
|
def clear_all():
|
||||||
conn = connect()
|
conn = connect()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute('DELETE FROM messages')
|
owner = visitor_id()
|
||||||
cur.execute('DELETE FROM chat_history')
|
owned = 'SELECT id FROM chat_history WHERE visitor_id=? AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))'
|
||||||
|
scope = (owner,current_customer_id(),current_scope_key())
|
||||||
|
cur.execute('DELETE FROM human_messages WHERE session_id IN (SELECT id FROM human_sessions '
|
||||||
|
'WHERE conv_id IN ('+owned+'))', scope)
|
||||||
|
cur.execute('DELETE FROM human_sessions WHERE conv_id IN ('+owned+')', scope)
|
||||||
|
cur.execute('DELETE FROM messages WHERE conv_id IN ('+owned+')', scope)
|
||||||
|
cur.execute('DELETE FROM chat_history WHERE id IN ('+owned+')', scope)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from flask import current_app, jsonify, request
|
|||||||
|
|
||||||
def rate_limit():
|
def rate_limit():
|
||||||
path = request.path
|
path = request.path
|
||||||
if path == '/healthz' or path.startswith('/static') or path.startswith('/api/human/'):
|
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
|
||||||
return None
|
return None
|
||||||
ip = request.remote_addr or 'unknown'
|
ip = request.remote_addr or 'unknown'
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -21,7 +21,7 @@ def rate_limit():
|
|||||||
|
|
||||||
|
|
||||||
def no_cache_html(response):
|
def no_cache_html(response):
|
||||||
if response.mimetype in ('text/html', 'text/event-stream'):
|
if response.mimetype in ('text/html', 'text/event-stream') or request.path.startswith('/api/'):
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
response.headers['Expires'] = '0'
|
response.headers['Expires'] = '0'
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
"""Human conversation lifecycle; typing state is supplied by the app."""
|
"""Human conversation lifecycle; typing state is supplied by the app."""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
|
||||||
|
SERVICE_TIMEZONE = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
def human_request(data):
|
def human_request(data):
|
||||||
conv_id = data.get('conv_id')
|
conv_id = data.get('conv_id')
|
||||||
if not conv_id:
|
if not conv_id:
|
||||||
return ({'ok': False, 'error': 'no conv'}, 400)
|
return ({'ok': False, 'error': 'no conv'}, 400)
|
||||||
if not 8 <= datetime.now().hour < 22:
|
service_time = datetime.now(SERVICE_TIMEZONE)
|
||||||
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天8:00-22:00,当前为非工作时间,请留言'}
|
if not 8 <= service_time.hour < 22:
|
||||||
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
return {'ok': False, 'closed': True, 'msg': '人工客服工作时间为每天北京时间8:00-22:00,当前为非工作时间,请留言'}
|
||||||
|
now = service_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
conn = connect()
|
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()
|
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:
|
if row:
|
||||||
sid = row[0]
|
sid = row[0]
|
||||||
@@ -79,10 +86,11 @@ def human_active():
|
|||||||
def human_accept(data):
|
def human_accept(data):
|
||||||
sid = data.get('sid')
|
sid = data.get('sid')
|
||||||
conn = connect()
|
conn = connect()
|
||||||
conn.execute("UPDATE human_sessions SET status='active' WHERE id=?", (sid,))
|
changed = conn.execute("UPDATE human_sessions SET status='active' WHERE id=? AND status IN ('waiting','active') "
|
||||||
|
'AND EXISTS(SELECT 1 FROM chat_history WHERE id=human_sessions.conv_id)', (sid,)).rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {'ok': True}
|
return {'ok': True} if changed else ({'ok':False, 'error':'session_closed_or_missing'}, 409)
|
||||||
|
|
||||||
|
|
||||||
def human_close(data):
|
def human_close(data):
|
||||||
|
|||||||
+207
-22
@@ -2,24 +2,209 @@
|
|||||||
|
|
||||||
|
|
||||||
knowledge_list = [
|
knowledge_list = [
|
||||||
|
# ==================== 一、订单 ====================
|
||||||
{
|
{
|
||||||
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家"],
|
"keys": ["查询订单", "查订单", "订单查询", "订单在哪里", "我的订单在哪", "帮我查订单", "为什么查不到订单"],
|
||||||
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓。"
|
"text": (
|
||||||
|
"请提供订单号、入仓号或关联运单号,并核验订单归属。"
|
||||||
|
"查询结果以实际记录为准。查不到时,应核对号码、查询范围和数据同步情况,"
|
||||||
|
"不能直接认定没有发货,也不能编造运输或清关结果。"
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["海运","船运","海派","海运快线","樱花快线"],
|
"keys": ["订单号和运单号", "订单号是什么", "运单号是什么", "入仓号是什么", "入仓码是什么", "一票多个单号", "主子订单"],
|
||||||
|
"text": (
|
||||||
|
"订单号识别业务订单,入仓号用于收货归集,运单号识别运输任务或包裹。"
|
||||||
|
"一票货可能因分包、分批或阶段换号产生多个编号,"
|
||||||
|
"应根据实际关联记录分别确认,不能把换号当成新增货物。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["付款后没发货", "付款了没发货", "下单后没发货", "下单后多久发货", "修改订单", "订单能修改吗", "取消订单", "订单能取消吗", "更换渠道"],
|
||||||
|
"text": (
|
||||||
|
"下单或付款成功不代表已出运,还需核对收货、审核和运输安排。"
|
||||||
|
"修改、取消或换渠道需根据当前操作阶段确认。"
|
||||||
|
"变更申请不等于执行成功,是否产生费用按适用规则确认。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 二、客户信息 ====================
|
||||||
|
{
|
||||||
|
"keys": ["手机号查订单", "用手机号查", "客户编号查订单", "客户号查订单", "公司名称查订单", "用公司名查", "代客户查询", "帮别人查订单"],
|
||||||
|
"text": (
|
||||||
|
"可按系统支持的方式辅助查找,但必须核验身份、订单归属及代理权限。"
|
||||||
|
"不能仅凭手机号或公司名称披露他人的完整订单、地址和联系方式。"
|
||||||
|
"查询权限与修改权限需要分别确认。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["联系人填错", "联系人写错", "手机号填错", "电话填错", "邮箱填错", "客户资料修改", "客户信息修改"],
|
||||||
|
"text": (
|
||||||
|
"客户账户资料与本票收发货资料需要分别管理。"
|
||||||
|
"修改账户资料不一定同步修改历史和在途订单。"
|
||||||
|
"在途信息变更需确认相关操作方是否接受并生效。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["订单负责人", "谁负责这票", "客户待办", "没收到通知"],
|
||||||
|
"text": (
|
||||||
|
"负责人、客户待办和通知情况应根据实际记录确认。"
|
||||||
|
"待办需说明具体事项、提交渠道及已确认的期限。"
|
||||||
|
"没有联系或送达记录时,不能认定客户已收到通知;"
|
||||||
|
"没有分配记录时,不能编造负责人和反馈时间。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 三、地址 ====================
|
||||||
|
{
|
||||||
|
"keys": ["收货地址怎么填", "地址怎么填写", "地址怎么写", "邮编不对", "邮编错误", "地址不完整", "地址不全", "缺少门牌"],
|
||||||
|
"text": (
|
||||||
|
"国际收货地址通常需要国家、州省、城市、街道、门牌、邮编、收件人及电话。"
|
||||||
|
"缺失或不匹配可能影响派送,具体要求以目的地和承运商为准。"
|
||||||
|
"补交资料后仍需确认是否修改成功。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["最终送到哪里", "最后送到哪里", "收货地址确认", "目的地址是什么", "提货地址和收货地址", "提货地址一样吗"],
|
||||||
|
"text": (
|
||||||
|
"提货、交仓、中转、清关和最终收货地址用途不同。"
|
||||||
|
"最终目的地应查询本票已生效的地址,并按权限展示。"
|
||||||
|
"到达中转仓或清关场所,不代表到达最终收货地址。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["修改收货地址", "改地址", "地址可以修改吗", "可以改地址吗", "更换收件人", "收件人可以改吗", "收件人改地址", "改送其他仓", "换仓地址", "FBA地址确认"],
|
||||||
|
"text": (
|
||||||
|
"改址需要核对货物阶段和执行方处理能力,"
|
||||||
|
"未确认生效前不能告知新地址已启用。"
|
||||||
|
"FBA或海外仓需核对当前货件完整地址及仓代码,"
|
||||||
|
"不能仅按历史仓代码表安排配送。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 四、节点 ====================
|
||||||
|
{
|
||||||
|
"keys": ["节点是什么意思", "什么叫节点", "节点和轨迹", "节点是什么", "已提交是什么意思", "订单已提交", "已接单是什么意思"],
|
||||||
|
"text": (
|
||||||
|
"节点是某一步发生的事件,轨迹是多个运输事件组成的记录。"
|
||||||
|
"已提交、审核通过、已接单属于业务处理状态,"
|
||||||
|
"本身不能证明仓库收到实物或货物已经出运。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["已预报是什么意思", "预报是什么意思", "已入仓是什么意思", "入仓是什么意思", "已装柜是什么意思", "装柜是什么意思", "已出运是什么意思", "出运了吗"],
|
||||||
|
"text": (
|
||||||
|
"已预报通常表示信息已提交,不一定已交实物;"
|
||||||
|
"已入仓表示仓库收货,不一定完成检查;"
|
||||||
|
"已装柜表示完成装载,不代表已经离港。"
|
||||||
|
"已出运需有实际离港、起飞、发车或交运依据。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["到港是什么意思", "到港了吗", "已提取是什么意思", "提取是什么意思", "已交末端是什么意思", "交末端是什么意思", "派送中是什么意思", "开始派送了吗"],
|
||||||
|
"text": (
|
||||||
|
"到港表示到达港口,之后可能仍需清关和交接。"
|
||||||
|
"已提取、已交末端通常表示当地承运商接收货物,需要核对接收方。"
|
||||||
|
"派送中表示进入配送环节,以上均不等于收件人签收。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 五、轨迹 ====================
|
||||||
|
{
|
||||||
|
"keys": ["完整物流轨迹", "物流轨迹怎么查", "运输轨迹怎么查", "有单号没有轨迹", "有单号查不到", "轨迹好几天没更新", "物流几天没更新", "轨迹不更新"],
|
||||||
|
"text": (
|
||||||
|
"轨迹应说明数据来源、覆盖范围及最后有效事件时间。"
|
||||||
|
"暂无记录可能涉及未交接、扫描未回传或查询源异常,需要核实。"
|
||||||
|
"长期没有更新不能直接认定丢失,没有数据的阶段不能自行补齐。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["换单号后怎么查", "单号换了", "新旧单号", "官网轨迹不一样", "轨迹不一致", "节点重复", "节点时间不一致"],
|
||||||
|
"text": (
|
||||||
|
"换号需保留新旧编号关联。"
|
||||||
|
"不同来源或节点时间不一致时,应核对时区、原始事件、回传及更正记录。"
|
||||||
|
"同步时间较新不一定代表事件较新,排查方向不能当作已确认原因。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["实时位置", "货物现在在哪", "预计到达时间", "预计什么时候到", "轨迹能证明签收吗", "轨迹能当到货证明吗", "模拟轨迹"],
|
||||||
|
"text": (
|
||||||
|
"节点地点通常表示某时经过该处,不等于实时定位。"
|
||||||
|
"预计到达时间属于计划,应注明来源和条件。"
|
||||||
|
"签收争议需核对POD等凭证,演示或模拟轨迹不能证明真实运输和交付。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 六、货物状态 ====================
|
||||||
|
{
|
||||||
|
"keys": ["履约中是什么意思", "履约中代表什么", "头程完成是什么意思", "头程完成了吗", "订单完成就是签收吗", "订单完成是不是签收"],
|
||||||
|
"text": (
|
||||||
|
"履约中表示订单正在执行,不能据此判断具体位置或清关阶段。"
|
||||||
|
"当前系统的头程订单已完成,不等于末端已签收,"
|
||||||
|
"也不能直接证明客户已收到货物。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["货物是否完好", "货物完好吗", "有没有少件", "少了几件", "货物受潮", "货物进水", "部分到货", "部分签收", "只到了一部分"],
|
||||||
|
"text": (
|
||||||
|
"货况需依据检查、交接件数和照片等记录确认,运输正常不能证明完好。"
|
||||||
|
"部分到货或签收时,应分别核对总件数及各件进度。"
|
||||||
|
"没有检查依据时说明尚未核实,不能用一件状态代表整票。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["已签收但没收到", "显示签收没收到", "签收人不认识", "谁签收的", "货物异常", "物流异常", "滞留是什么意思", "货物滞留"],
|
||||||
|
"text": (
|
||||||
|
"签收争议应核对签收人、交付地点、代收及POD,必要时调查。"
|
||||||
|
"异常或滞留需明确发生环节和实际原因,不能直接等同海关扣货。"
|
||||||
|
"调查前不能擅自认定货物丢失或客户已经收到。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
# ==================== 七、清关 ====================
|
||||||
|
{
|
||||||
|
"keys": ["清关了吗", "我的货清关了吗", "是否清关", "清关完成没有", "清关完成了吗", "有没有放行", "出口放行就是清关完成吗"],
|
||||||
|
"text": (
|
||||||
|
"查询清关需明确国家、出口或进口环节,并核对有效记录。"
|
||||||
|
"出口放行不代表目的国进口清关完成,到港和已付款也不能证明放行。"
|
||||||
|
"没有可靠数据时应说明暂未取得结果,不能把未知当作已完成或未完成。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["已申报是什么意思", "申报了吗", "清关中是什么意思", "现在清关中吗", "查验是不是没收", "被查验了吗", "清关为什么慢", "清关怎么还没好"],
|
||||||
|
"text": (
|
||||||
|
"申报动作、申报受理和放行需分别核实;"
|
||||||
|
"清关中表示手续正在处理,查验不等于没收。"
|
||||||
|
"具体等待原因以实际反馈为准,"
|
||||||
|
"不能保证统一天数或一定放行,清关状态也不一定按固定顺序变化。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys": ["清关完为什么没派送", "清关了怎么没派送", "整票都清关了吗", "是不是整票放行", "税款交了吗", "税交了吗", "放行证明", "清关证明"],
|
||||||
|
"text": (
|
||||||
|
"资料、税费和放行状态需分别核实,客户付款不一定代表税款已缴。"
|
||||||
|
"放行范围需核对申报单、批次及包裹,不能用一件代表整票。"
|
||||||
|
"放行后可能仍需提货和末端交接;凭证提供需核对关联、授权和脱敏要求。"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys":["公司","柏悦","柏悦物流","介绍","哪家","哪家货代","你们是哪家","公司地址","办公地址","洛杉矶地址","纽约地址"],
|
||||||
|
"text":"浙江柏悦物流位于义乌,主营中欧跨境物流专线,依托义乌、宁波口岸优势,服务中小跨境卖家。主要渠道:欧洲海运快线、义乌-中欧铁路、中欧卡航、欧洲空运(空派)、英国空运,均支持欧洲、英国双清包税与VAT递延。义乌、泉州、广州、深圳均可交仓,公司地址位于:洛杉矶:10767 7th Street, Rancho Cucamonga, California 91730纽约:Door 13-14,75 Carter Dr Edison, New Jersey 08817义乌:浙江省义乌市福田街道稠州北路 1399 号 8613-8615 室深圳:深圳市南山区粤海街道海珠社区海德三道 1258 号卓越后海金融中心 1101。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"keys":["海运","船运","海派","海运快线","樱花快线","欧洲海运价格","海运多少钱","海运多久"],
|
||||||
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
|
"text":"欧洲海运快线(双清/递延)单价:21KG+ 12.3元/kg,101KG+ 10.3元/kg,500KG+ 10.2元/kg,2000KG+ 10.0元/kg。时效:开船后38-45天提取。可走樱花快线(+0.3元/kg),递延价减1.7元/kg。主派送为UPS/DHL/DPD/CWE统配。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["铁路","中欧班列","班列","义乌铁路","火车"],
|
"keys":["铁路","中欧班列","班列","义乌铁路","火车","铁路价格","铁路多久","铁路多少钱"],
|
||||||
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
|
"text":"义乌-中欧铁路专线单价:21KG+ 15.5元/kg,100KG+ 和 1000KG+ 均为13.5元/kg。时效:发车后36-40天提取。VAT递延减1元/kg。只限送义乌仓,义乌装柜经阿拉山口/霍尔果斯出境。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["卡航","卡车","卡派"],
|
"keys":["卡航","卡车","卡派","卡航价格","卡航多久","卡航多少钱"],
|
||||||
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
|
"text":"中欧卡航单价:21KG+ 19.4元/kg,101KG+ 和 500KG+ 17.4元/kg,2000KG+ 及 4000KG+ 17.3元/kg。时效:发车后欧盟16-20天提取。递延减1.5元/kg,普卡时效18-22天。纯纺织品另加5元/kg。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["空运","空派","航空","欧洲空运"],
|
"keys":["空运","空派","航空","欧洲空运","空运价格","空运多久","空运多少钱"],
|
||||||
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
|
"text":"欧洲空运专线(空派)单价:21KG+ 39元/kg,45KG+ 38元/kg,71KG+ 37元/kg,101KG+ 36元/kg。时效:10-13天左右提取;快线8-13天,直飞快线12-15天。一票多件单件不足12KG按12KG计费。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,67 +212,67 @@ knowledge_list = [
|
|||||||
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
|
"text":"英国专线(DPD)双清价格:英国海运21KG+ 12.8元/kg、101KG+/301KG+ 10.9元/kg,时效约40天提取;英国铁路21KG+ 18.0元/kg、101KG+ 16.2元/kg,发车后25-30天提取;中英卡航21KG+ 21.4元/kg、101KG+ 19.4元/kg,发车后20-22天提取。VAT自税/递延价更低(海运10.0/8.1、铁路15/13.2、卡航18.4/16.4)。北极航线英国海运25-28天,价格单询。卡航单件不超30kg、尺寸≤98×68×59cm。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["英国空运","英国","UK","伦敦"],
|
"keys":["英国空运","英国空运价格","英国空运多久","英国空运多少钱","英国","UK","伦敦"],
|
||||||
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
|
"text":"英国空运专线(双清)单价:22KG+ 48元/kg,51KG+ 44元/kg,101KG+ 42元/kg,时效8-10个自然日提取。VAT自税/递延为44/40/38元/kg,带电货47.5/42.5/41.5元/kg。拒收北爱尔兰、苏格兰高地等偏远地址。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["报价","价格","费用","计费","怎么收费","计费重量","多少钱","运费","单价"],
|
"keys":["报价","价格","费用","计费","怎么收费","计费重量","计费重量怎么算","材积重","体积重","多少钱","运费","运费怎么算","单价"],
|
||||||
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
|
"text":"运费计算方式:阶梯单价 × 计费重量。计费重量 = 实重与材积重取大值,材积 = 长×宽×高(cm)÷6000,即1CBM=167KG。各渠道最低起收21KG(英国空运22KG)。单件计费重不足13KG的渠道(铁路、卡航)按5元/kg加收超轻费;空运单件不足12KG按12KG计费。偏远地址加7元/kg,单票最低250元。价格不含税,税点以开出的发票为准。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["时效","多久","要几天","多长时间"],
|
"keys":["时效","运输时效","多久","要几天","多长时间","几天到","什么时候到"],
|
||||||
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
|
"text":"各渠道时效:欧洲海运快线开船后38-45天提取;中欧铁路发车后36-40天提取;中欧卡航发车后16-20天提取;欧洲空运10-13天提取;英国空运8-10个自然日提取。海关查验、罢工、节假日等不可抗力导致的时间不计入。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["揽收","上门取件","上门收货","交仓","交货"],
|
"keys":["揽收","上门取件","上门收货","可以上门取货吗","交仓","交货","哪里交仓"],
|
||||||
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
|
"text":"义乌、深圳(龙华观澜、宝安福园)、广州(石井)、泉州均可交仓,义乌仓10:00-21:00收货。欧洲空运交仓义乌+1元/kg,泉州、广州+0.5元/kg;海运、铁路、卡航泉州交货+0.5元/kg。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","交仓地址","送仓"],
|
"keys":["入仓","进仓","仓库收货","仓库地址","仓库在哪","义乌仓","深圳仓","广州仓","交仓地址","送仓"],
|
||||||
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
|
"text":"义乌仓:义乌市稠江街道梦娜袜业德贸路东三门进,李东文 19558263692(10:00-21:00)。深圳龙华:龙华区观澜街道君子布社区张一村路黄背坑路1号B栋,张长印 18688937838。深圳宝安:福园二路九号海亨通物流B区C09-C14。广州:恒河沙仓储石井仓211,潘志红 13789206500。送货需打印交接清单随货交仓。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔"],
|
"keys":["理赔","货损","丢件","赔付","破损","压坏","索赔","怎么理赔","丢件怎么赔","理赔多久"],
|
||||||
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
|
"text":"遗失赔付:未提取上网遗失按申报价值赔偿,最高不超过40元/kg,不退运费;已提取遗失按服务商标准,最高100USD/票。开查时间:交仓后7天未提取可申请开查,开查30天无果走索赔。签收后亚马逊未上架需2周内提出调查,包裹异常需1个月内提出。外包装无损的内件丢失不支持理赔,易碎品损坏不支持理赔。可自购保险,保费为货值千分之三(电池千分之五)。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["超轻费","计费重量不足","最低计费"],
|
"keys":["超轻费","计费重量不足","最低计费","重量不够怎么收费"],
|
||||||
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
|
"text":"单件计费重量不得低于13KG(空运12KG),可整票按平均13KG算,不足部分按5元/kg加收超轻费。例:单箱只有12KG,加收5×1=5元超轻费。欧洲热门仓卡派直送要求单件平均计费重12KG,不足按2元/kg加收派送费。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["偏远","偏远费","偏远地址"],
|
"keys":["偏远","偏远费","偏远地址","偏远怎么查","偏远收费"],
|
||||||
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
|
"text":"偏远地址加收7元/kg(不含燃油),单票偏远费最低消费250元/票。需要自行到UPS/DPD官网查询目的地址是否偏远,我司出货时不另行通知,以账单为准,偏远费6个月内有效。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["超重","超长","超大","尺寸限制"],
|
"keys":["超重","超长","超大","尺寸限制","尺寸超了","超尺寸怎么收费"],
|
||||||
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
|
"text":"欧洲空运最长边≤99cm、次长边≤75cm、实重<24KG。英国DPD单件≤30KG,尺寸100×70×60cm以内,超重或超长各加收330元/件。UPS单边最长≤99cm、次边≤75cm、单件<24KG,超出加收240元+100元。热门仓卡派直送:单件超40KG加收30元/件。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["敏感","带电","电池","液体","粉末","禁品","不能寄","违禁品","拒收","拒接"],
|
"keys":["敏感","敏感货能发吗","带电","电池","液体","粉末","禁品","不能寄","什么不能寄","违禁品","拒收","拒接"],
|
||||||
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
|
"text":"可接带标准箱100瓦以下非危电池/内电产品(C卡航、海运快线均可),资料齐全加1元/kg,资料不齐加2元/kg;手机、平板电脑加5元/kg。拒接:纯液体、纯粉末、药品、食品、易燃易爆、仿牌侵权、木制品(空运)、陶瓷手表等反倾销品、电子烟、管制刀具、枪支。电池第一个托收400元/件,第二个起100元/件,600元封顶。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["清关","递延","VAT","双清","包税","DDP"],
|
"keys":["清关","清关资料","清关要什么资料","递延","VAT","VAT递延","双清","双清包税","包税","DDP"],
|
||||||
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
|
"text":"欧洲、英国渠道均支持双清包税与VAT递延(只付关税,增值税销售后缴纳)。递延需提供法人护照/身份证、欧盟VAT和EORI号及回执、营业执照、4份模板文件原件扫描件PDF。罗马尼亚等欧盟国家有二次清关风险,二次关税由客户自行缴纳。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["报关","单独报关","报关费"],
|
"keys":["报关","怎么报关","单独报关","报关费","报关多少钱"],
|
||||||
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
|
"text":"单独报关费用:空运350元/票,海运200元/票,铁路250元/票,卡航200元/票,续页费50元/页。一达通报关500元/票。单票品名最多5个,第6个起加收30元/个。报价不含税,税点以开出的发票为准。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["亚马逊","FBA","海外仓","仓库配送","卡派"],
|
"keys":["亚马逊","亚马逊仓","FBA","FBA头程","海外仓","仓库配送","卡派直送"],
|
||||||
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
|
"text":"支持FBA头程送亚马逊仓,热门仓卡派直送(德国WRO5/HAJ1/DTM1/DTM2、法国CDG7/LIL8、意大利TRN3、西班牙BCN8、罗马尼亚EMAG等)。卡派直送优势:超重附加费少、不限尺寸重量、上架快、基本无丢件。法国、意大利、西班牙其他亚马逊仓不能超UPS标准。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
|
"keys":["人工客服","人工","客服","转人工","在线吗","上班","工作时间","营业时间","几点","周六","周日","星期六","星期日","星期天","礼拜","周末","双休","节假日","放假","休息"],
|
||||||
"text":"人工客服在线时间:周一至周五 9:00—18:00。周六、周日及法定节假日人工客服不上班。非工作时间可以先留言或提交需求,业务员会在下一个工作日回复您。"
|
"text":"人工客服在线时间:每天北京时间 8:00—22:00,周末及节假日同样适用。非工作时间可以先留言或提交需求,客服会在工作时间尽快回复您。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
|
"keys":["电话","联系方式","怎么联系","联系你们","微信"],
|
||||||
"text":"可以联系您的专属业务员,或在人工客服在线时间(周一至周五 9:00—18:00)通过在线客服咨询。建议提前准备好运单号、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
"text":"可以联系您的专属业务员,或在人工客服在线时间(每天北京时间 8:00—22:00)通过在线客服咨询。建议提前准备好入仓码、货物信息(品名、重量、尺寸、目的国),方便快速处理。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys":["退件","退回","退货","退运","改地址"],
|
"keys":["退件","退回","退货","退运","怎么退运"],
|
||||||
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
|
"text":"海运/铁路为散货拼柜,已装柜不接受退件和扣件。空运国外扣件费200欧/票,收件人改派地址最低200元/票。退回海外仓:德国境内15欧/件,欧盟境内25欧/件,操作费100元/票,5个工作日免仓储,超期1欧/kg/天。重派德国10欧/件、欧盟20欧/件。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""Local, editable order test data. This repository can later be replaced by MCP."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
from .order_tracking import build_tracking
|
||||||
|
from .visitor_service import customer_tenant_ids
|
||||||
|
from .customer_service import order_login_response
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
|
||||||
|
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
|
||||||
|
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
|
||||||
|
'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
|
||||||
|
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||||
|
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||||
|
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||||
|
FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
|
||||||
|
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||||
|
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||||
|
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||||
|
IDENTIFIER = re.compile(r'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
|
||||||
|
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|预计|什么时候|何时|多久|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDataUnavailable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_order_table(connection):
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS order_test_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source_order_id TEXT, tenant_id TEXT NOT NULL DEFAULT 'test',
|
||||||
|
order_no TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
inbound_code TEXT COLLATE NOCASE, shipment_id TEXT COLLATE NOCASE,
|
||||||
|
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
|
||||||
|
order_role TEXT, parent_order_no TEXT,
|
||||||
|
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
|
||||||
|
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
|
||||||
|
source_updated_at TEXT, snapshot_at TEXT,
|
||||||
|
UNIQUE(tenant_id, order_no)
|
||||||
|
)''')
|
||||||
|
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
|
||||||
|
if 'tracking_json' not in columns:
|
||||||
|
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
|
||||||
|
|
||||||
|
|
||||||
|
def import_records(connection, records, replace=False):
|
||||||
|
"""Import an explicitly supplied snapshot; preserve manual edits unless requested."""
|
||||||
|
init_order_table(connection)
|
||||||
|
inserted = skipped = 0
|
||||||
|
for source in records:
|
||||||
|
if not isinstance(source, dict) or not str(source.get('order_no') or '').strip():
|
||||||
|
raise ValueError('Each record requires an order_no')
|
||||||
|
row = {key: source.get(key) for key in FIELDS}
|
||||||
|
row['tenant_id'] = str(row['tenant_id'] or 'test')
|
||||||
|
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
|
||||||
|
row['order_no'] = str(row['order_no']).strip()
|
||||||
|
row['status'] = str(row['status'] or 'DRAFT')
|
||||||
|
history = row['history_json'] or []
|
||||||
|
if isinstance(history, str): history = json.loads(history)
|
||||||
|
if not isinstance(history, list): raise ValueError('history_json must contain a list')
|
||||||
|
row['history_json'] = json.dumps(history, ensure_ascii=False)
|
||||||
|
tracking = row['tracking_json'] or []
|
||||||
|
if isinstance(tracking, str): tracking = json.loads(tracking)
|
||||||
|
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
|
||||||
|
row['tracking_json'] = json.dumps(tracking, ensure_ascii=False)
|
||||||
|
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
|
||||||
|
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
|
||||||
|
(row['tenant_id'], row['order_no'])).fetchone()
|
||||||
|
if exists and not replace:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
columns = ','.join(FIELDS)
|
||||||
|
placeholders = ','.join('?' for _ in FIELDS)
|
||||||
|
updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no'))
|
||||||
|
connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) '
|
||||||
|
f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS])
|
||||||
|
inserted += 1
|
||||||
|
return {'imported': inserted, 'preserved': skipped}
|
||||||
|
|
||||||
|
|
||||||
|
def import_tracking_records(connection, records, replace=False):
|
||||||
|
"""Update tracking alone; never replace hand-edited order fields or add unknown orders."""
|
||||||
|
init_order_table(connection)
|
||||||
|
imported = preserved = missing = 0
|
||||||
|
for record in records:
|
||||||
|
if not isinstance(record, dict) or not record.get('tenant_id') or not record.get('order_no'):
|
||||||
|
raise ValueError('Tracking record requires tenant_id and order_no')
|
||||||
|
tracking = record.get('tracking_json')
|
||||||
|
if isinstance(tracking, str): tracking = json.loads(tracking)
|
||||||
|
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
|
||||||
|
row = connection.execute('SELECT id,source_order_id,tracking_json FROM order_test_data '
|
||||||
|
'WHERE tenant_id=? AND order_no=?',
|
||||||
|
(str(record['tenant_id']), record['order_no'])).fetchone()
|
||||||
|
if not row:
|
||||||
|
missing += 1
|
||||||
|
continue
|
||||||
|
if not record.get('source_order_id') or str(record['source_order_id']) != str(row[1]):
|
||||||
|
raise ValueError('Tracking source_order_id does not match local order')
|
||||||
|
if not replace and row[2] not in (None, '', '[]'):
|
||||||
|
preserved += 1
|
||||||
|
continue
|
||||||
|
connection.execute('UPDATE order_test_data SET tracking_json=? WHERE id=?',
|
||||||
|
(json.dumps(tracking, ensure_ascii=False), row[0]))
|
||||||
|
imported += 1
|
||||||
|
return {'imported': imported, 'preserved': preserved, 'missing': missing}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_filter(tenant_id=None, tenant_ids=None):
|
||||||
|
"""Share the same server-side scope intersection for exact and aggregate reads."""
|
||||||
|
tenant = get_setting('ORDER_DATA_TENANT_ID')
|
||||||
|
if tenant_ids is not None:
|
||||||
|
if not isinstance(tenant_ids,(tuple,list,set)) or not 0 < len(tenant_ids) <= 256:
|
||||||
|
return None
|
||||||
|
allowed = {value for value in tenant_ids if isinstance(value,str) and 0 < len(value.strip()) <= 128}
|
||||||
|
if tenant_id is not None:
|
||||||
|
allowed.intersection_update({str(tenant_id)})
|
||||||
|
if tenant:
|
||||||
|
allowed.intersection_update({str(tenant)})
|
||||||
|
return (' AND tenant_id IN (' + ','.join('?' for _ in allowed) + ')', sorted(allowed)) if allowed else None
|
||||||
|
if tenant_id is not None:
|
||||||
|
if not str(tenant_id).strip() or (tenant and str(tenant_id) != str(tenant)):
|
||||||
|
return None
|
||||||
|
tenant = str(tenant_id)
|
||||||
|
return (' AND tenant_id=?', [str(tenant)]) if tenant else ('', [])
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_order(reference, tenant_id=None, tenant_ids=None):
|
||||||
|
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
scope = tenant_filter(tenant_id, tenant_ids)
|
||||||
|
if scope is None:
|
||||||
|
return []
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = connect()
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
query = 'SELECT * FROM order_test_data WHERE (order_no=? OR inbound_code=? OR shipment_id=?)' + scope[0]
|
||||||
|
values = [reference] * 3 + scope[1]
|
||||||
|
return [dict(row) for row in connection.execute(query+' ORDER BY id LIMIT 2', values)]
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise OrderDataUnavailable from None
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def count_orders(tenant_ids, status=None):
|
||||||
|
"""Fresh totals require an explicit authorized scope; never fall back to all rows."""
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
empty = {'total':0, 'with_inbound':0}
|
||||||
|
scope = tenant_filter(tenant_ids=tenant_ids if tenant_ids is not None else ())
|
||||||
|
if scope is None or (status is not None and status not in STATUS_LABELS):
|
||||||
|
return empty
|
||||||
|
connection = None
|
||||||
|
try:
|
||||||
|
connection = connect()
|
||||||
|
query = ("SELECT COUNT(*),COUNT(NULLIF(TRIM(inbound_code,char(9)||char(10)||char(13)||' '),'')) "
|
||||||
|
'FROM order_test_data WHERE 1=1' + scope[0])
|
||||||
|
values = scope[1]
|
||||||
|
if status is not None:
|
||||||
|
query += ' AND status=?'
|
||||||
|
values = values + [status]
|
||||||
|
total, with_inbound = connection.execute(query,values).fetchone()
|
||||||
|
return {'total':total, 'with_inbound':with_inbound}
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise OrderDataUnavailable from None
|
||||||
|
finally:
|
||||||
|
if connection is not None:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_count_query(message):
|
||||||
|
"""Recognize bounded total/status questions without ignoring unknown filters."""
|
||||||
|
if is_lookup_help(message):
|
||||||
|
return None
|
||||||
|
noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
|
||||||
|
count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
|
||||||
|
if not re.search(noun,message,re.I) or not re.search(count,message,re.I):
|
||||||
|
return None
|
||||||
|
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):
|
||||||
|
return None
|
||||||
|
remaining = message
|
||||||
|
statuses = []
|
||||||
|
for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
|
||||||
|
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
|
||||||
|
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')):
|
||||||
|
if phrase in remaining:
|
||||||
|
statuses.append(status)
|
||||||
|
remaining = remaining.replace(phrase,'')
|
||||||
|
remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I)
|
||||||
|
remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
|
||||||
|
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
|
||||||
|
r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I)
|
||||||
|
return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def answer_count_query(intent, tenants):
|
||||||
|
if not tenants:
|
||||||
|
return order_login_response()
|
||||||
|
if intent['unsupported']:
|
||||||
|
return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'}
|
||||||
|
try:
|
||||||
|
counts = count_orders(tenants,intent['status'])
|
||||||
|
except OrderDataUnavailable:
|
||||||
|
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||||
|
if intent['status']:
|
||||||
|
text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单"
|
||||||
|
else:
|
||||||
|
text = f"您当前共有 {counts['total']} 票订单"
|
||||||
|
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
|
||||||
|
|
||||||
|
|
||||||
|
def answer_list_query(message, tenants):
|
||||||
|
"""Reuse bounded status parsing; list reads require an explicit customer scope."""
|
||||||
|
if is_lookup_help(message) or not re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I):
|
||||||
|
return None
|
||||||
|
if not re.search(r'列出|列表|全部|所有|\blist\b',message,re.I):
|
||||||
|
return None
|
||||||
|
if not tenants:
|
||||||
|
return order_login_response()
|
||||||
|
question=re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)
|
||||||
|
intent=parse_count_query(question)
|
||||||
|
if intent is None or intent['unsupported']:
|
||||||
|
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
|
||||||
|
scope=tenant_filter(tenant_ids=tenants)
|
||||||
|
if scope is None:
|
||||||
|
return order_login_response()
|
||||||
|
from .db import connect
|
||||||
|
connection=None
|
||||||
|
try:
|
||||||
|
connection=connect()
|
||||||
|
sql='SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data WHERE 1=1'+scope[0]
|
||||||
|
values=scope[1]
|
||||||
|
if intent['status']:
|
||||||
|
sql+=' AND status=?';values=values+[intent['status']]
|
||||||
|
rows=connection.execute(sql+' ORDER BY id DESC LIMIT 21',values).fetchall()
|
||||||
|
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
|
||||||
|
lines=['您当前的订单列表'+('(先列出20票)' if len(rows)>20 else '')+':']
|
||||||
|
for number,inbound,status,warehouse in rows[:20]:
|
||||||
|
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(status,status)};目的仓:{warehouse or "未登记"}')
|
||||||
|
lines.append('发送入仓码可查询该票订单和物流轨迹。')
|
||||||
|
return {'t':'\n'.join(lines),'_customer_data':True}
|
||||||
|
except sqlite3.Error:
|
||||||
|
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||||
|
finally:
|
||||||
|
if connection is not None:connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reference(message):
|
||||||
|
# Explicit postal/monetary fields are not order identifiers, even if invalid.
|
||||||
|
message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I)
|
||||||
|
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*(?:万)?(?:USD|RMB|CNY|美元|美金|人民币|元)(?![A-Za-z0-9_-])',' ',message,flags=re.I)
|
||||||
|
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'(?:kilograms?|kgs?|grams?|g|tons?|lbs?|磅|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
|
||||||
|
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
|
||||||
|
references = []
|
||||||
|
for token in IDENTIFIER.findall(message):
|
||||||
|
if (re.fullmatch(r'[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2}',token)
|
||||||
|
and re.search(r'美国|报价|海派|海卡|海运|\bquote\b',message,re.I)
|
||||||
|
and not re.search(r'订单|运单|入仓|轨迹|查单|\btrack\b',message,re.I)):
|
||||||
|
continue
|
||||||
|
if sum(character.isdigit() for character in token) < 3:
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
|
||||||
|
continue
|
||||||
|
if token.casefold() not in [value.casefold() for value in references]:
|
||||||
|
references.append(token)
|
||||||
|
return references
|
||||||
|
|
||||||
|
|
||||||
|
def build_card(row):
|
||||||
|
fields = []
|
||||||
|
def field(key, value):
|
||||||
|
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||||
|
field('inbound', row['inbound_code'])
|
||||||
|
if row['inbound_code']:
|
||||||
|
field('number', row['order_no'])
|
||||||
|
field('shipment', row['shipment_id'])
|
||||||
|
field('country', row['country_code'])
|
||||||
|
field('warehouse', row['warehouse_code'])
|
||||||
|
field('transport', {'SEA':'海运', 'AIR':'空运', 'RAIL':'铁路', 'TRUCK':'卡航'}.get(row['transport_mode'], row['transport_mode']))
|
||||||
|
field('packages', row['package_count'])
|
||||||
|
field('weight', str(row['gross_weight_kg'])+' kg' if row['gross_weight_kg'] is not None else None)
|
||||||
|
field('volume', str(row['volume_cbm'])+' m³' if row['volume_cbm'] is not None else None)
|
||||||
|
field('role', {'CHILD':'子订单', 'MASTER':'主订单'}.get(row['order_role']))
|
||||||
|
field('parent', row['parent_order_no'])
|
||||||
|
field('updated', row['source_updated_at'])
|
||||||
|
try:
|
||||||
|
history = json.loads(row['history_json'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
history = []
|
||||||
|
nodes = [{'time': str(event.get('time') or ''),
|
||||||
|
'text': STATUS_LABELS.get(str(event.get('status')), str(event.get('status') or '状态更新')), 'place': ''}
|
||||||
|
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||||
|
nodes.reverse()
|
||||||
|
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||||
|
inbound = row['inbound_code']
|
||||||
|
summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
|
||||||
|
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
|
||||||
|
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||||
|
'nodes':nodes, 'summary':summary}
|
||||||
|
tracking = build_tracking(row.get('tracking_json'))
|
||||||
|
if tracking:
|
||||||
|
card.update(node_kind='logistics', nodes=tracking['nodes'],
|
||||||
|
estimated_nodes=tracking['estimated_nodes'], tracking_status=tracking['status'])
|
||||||
|
facts = tracking['nodes'][:10] + tracking['estimated_nodes'][:5]
|
||||||
|
card['summary'] += '。物流状态:' + (tracking['status'] or '暂无实际节点') + '。'
|
||||||
|
card['summary'] += ';'.join(f"{node['time']} {node['text']} {node['place']}" for node in facts)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def is_lookup_help(message):
|
||||||
|
if re.search(r'是什么意思|指什么|怎么(?:查|操作|使用)|如何(?:查|操作|使用)|怎样(?:查|操作|使用)|'
|
||||||
|
r'\bhow\s+(?:do|can|to)\b', message, re.I):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r'\s*(?:请问)?(?:物流轨迹|物流状态|订单状态|入仓码|入仓号)是什么[。?!?!\s]*', message):
|
||||||
|
return True
|
||||||
|
return bool(re.search(r'\bwhat\s+(?:is|are)\b', message, re.I) and
|
||||||
|
not re.search(r'\b(?:my|this|that|our)\b', message, re.I))
|
||||||
|
|
||||||
|
|
||||||
|
def is_tracking_request(message):
|
||||||
|
"""Bounded tracking commands also work without an identifier or prior context."""
|
||||||
|
question = re.sub(r'[\s,,。.!!??、::]', '', message)
|
||||||
|
command = (r'(?:请问|请|麻烦)?(?:帮我)?(?:查(?:询|一下|下)?(?:物流(?:轨迹|进度|状态)?|轨迹|进度|货)|'
|
||||||
|
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
|
||||||
|
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
|
||||||
|
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
|
||||||
|
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
|
||||||
|
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
|
||||||
|
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
|
||||||
|
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
|
||||||
|
return bool(re.fullmatch(command+'|'+personal, question) or
|
||||||
|
re.fullmatch(english, message.strip(' \t\r\n.!?'), re.I))
|
||||||
|
|
||||||
|
|
||||||
|
def is_order_followup(message):
|
||||||
|
"""Reuse an order only for an explicit reference or a short detail question."""
|
||||||
|
if is_lookup_help(message):
|
||||||
|
return False
|
||||||
|
if is_tracking_request(message):
|
||||||
|
return True
|
||||||
|
if not re.search(ORDER_DETAIL, message):
|
||||||
|
return False
|
||||||
|
if re.search(r'(?:这|那|该)(?:个|票|单|笔)|该订单|刚才|上一(?:票|单|个订单)', message):
|
||||||
|
return True
|
||||||
|
question = re.sub(r'[\s,,。.!!??、::]', '', message)
|
||||||
|
details = (
|
||||||
|
r'(?:(?:(?:物流|运输)?(?:状态|进度|轨迹)|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
|
||||||
|
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
|
||||||
|
r'(?:呢|吗|呀|啊)?')
|
||||||
|
tracking = (
|
||||||
|
r'(?:(?:到哪(?:里)?|开船|到港|清关|提柜|还柜|签收)(?:了)?(?:呢|吗|没|了吗|没有)?|'
|
||||||
|
r'(?:预计)?(?:什么时候|何时|多久)(?:能|会|可以)?(?:到|到达|到港|到仓|到货|送到)(?:呢|呀|啊)?|还要多久)')
|
||||||
|
return bool(re.fullmatch(
|
||||||
|
r'(?:那|那么|还有|请问|请|查一下|看一下|现在|目前)?(?:它的|订单的)?'
|
||||||
|
r'(?:'+details+'|'+tracking+')', question))
|
||||||
|
|
||||||
|
|
||||||
|
def answer_query(message, conv_id):
|
||||||
|
references = extract_reference(message)
|
||||||
|
tenants = customer_tenant_ids()
|
||||||
|
count_intent = parse_count_query(message) if not references else None
|
||||||
|
if count_intent is not None:
|
||||||
|
return answer_count_query(count_intent,tenants)
|
||||||
|
if not references:
|
||||||
|
listed=answer_list_query(message,tenants)
|
||||||
|
if listed is not None:return listed
|
||||||
|
mutation=bool(re.search(r'改址|修改.*(?:收件|地址|订单)|收件地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
|
||||||
|
if references and not tenants:
|
||||||
|
return order_login_response()
|
||||||
|
if len(references) > 1:
|
||||||
|
return {'t':'请一次提供一个入仓码,避免查错订单。'}
|
||||||
|
reference = references[0] if references else None
|
||||||
|
if not reference and conv_id and is_order_followup(message):
|
||||||
|
from .db import connect
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
recent = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id DESC LIMIT 12', (conv_id,)).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
for role, content in recent:
|
||||||
|
if role == 'user':
|
||||||
|
earlier_references = extract_reference(content)
|
||||||
|
if earlier_references:
|
||||||
|
reference = earlier_references[0] if len(earlier_references) == 1 else None
|
||||||
|
break
|
||||||
|
if not is_order_followup(content):
|
||||||
|
break
|
||||||
|
if role == 'assistant' and content.startswith('__CARD__'):
|
||||||
|
try:
|
||||||
|
previous = json.loads(content[8:])
|
||||||
|
if previous.get('kind') == 'order':
|
||||||
|
reference = previous['no']
|
||||||
|
break
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
if not reference:
|
||||||
|
if mutation:
|
||||||
|
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
|
||||||
|
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||||
|
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||||
|
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||||
|
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||||
|
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I)):
|
||||||
|
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||||
|
return None
|
||||||
|
if not tenants:
|
||||||
|
return order_login_response()
|
||||||
|
try:
|
||||||
|
matches = lookup_order(reference,tenant_id=tenants[0]) if len(tenants) == 1 else lookup_order(reference,tenant_ids=tenants)
|
||||||
|
except OrderDataUnavailable:
|
||||||
|
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||||
|
if not matches:
|
||||||
|
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||||
|
if len(matches) > 1:
|
||||||
|
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||||
|
if mutation:
|
||||||
|
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
|
||||||
|
if re.search(r'运费|费用|多少钱|价格', message):
|
||||||
|
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
|
||||||
|
return {'card': build_card(matches[0])}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Customer tracking derived from explicit order-linked facts, without inference."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
# Maritime codes follow the firstleg standard; warehouse facts remain distinct.
|
||||||
|
NODE_LABELS = {
|
||||||
|
'DOMESTIC_RECEIVED': (0, '已入国内仓', '预计入国内仓'),
|
||||||
|
'LOADING_COMPLETED': (5, '已完成装柜', '预计完成装柜'),
|
||||||
|
'DEPARTED_WAREHOUSE': (10, '已离开国内仓库', '预计离开国内仓库'),
|
||||||
|
'AT_ORIGIN_PORT': (20, '重箱已进入起运港或码头', '预计进入起运港或码头'),
|
||||||
|
'LOADED_ON_VESSEL': (30, '已装船', '预计装船'),
|
||||||
|
'DEPARTED_ORIGIN_PORT': (40, '船舶已离开起运港', '预计离开起运港'),
|
||||||
|
'IN_TRANSIT': (50, '海运途中', '预计进入海运阶段'),
|
||||||
|
'ARRIVED_DESTINATION_PORT': (60, '船舶已到目的港', '预计到达目的港'),
|
||||||
|
'DISCHARGED_FROM_VESSEL': (70, '已卸船、未清关', '预计卸船'),
|
||||||
|
'PICKUP_READY': (80, '已清关、可提柜', '预计可提柜'),
|
||||||
|
'PICKED_UP': (90, '已提柜', '预计提柜'),
|
||||||
|
'EMPTY_RETURNED': (100, '已还柜', '预计还柜'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(value):
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
|
||||||
|
if result.tzinfo:
|
||||||
|
result = result.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return result
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_tracking(raw):
|
||||||
|
"""Latest observation corrects a milestone; forecasts never advance progress."""
|
||||||
|
try:
|
||||||
|
facts = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(facts, list):
|
||||||
|
return None
|
||||||
|
actual, estimated = {}, {}
|
||||||
|
for index, fact in enumerate(facts):
|
||||||
|
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
|
||||||
|
continue
|
||||||
|
shipment = str(fact.get('shipment_key') or '')
|
||||||
|
key = (shipment, fact['code'])
|
||||||
|
observation = parse_time(fact.get('observed_at')) or parse_time(fact.get('created_at')) or datetime.min
|
||||||
|
for field, target in (('actual_at', actual), ('estimated_at', estimated)):
|
||||||
|
when = parse_time(fact.get(field))
|
||||||
|
if when is None:
|
||||||
|
continue
|
||||||
|
candidate = (observation, index, when, fact)
|
||||||
|
if key not in target or candidate[:2] > target[key][:2]:
|
||||||
|
target[key] = candidate
|
||||||
|
if not actual and not estimated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
|
||||||
|
multiple = len(shipments) > 1
|
||||||
|
progress = {}
|
||||||
|
for (shipment, code), (_, _, when, fact) in actual.items():
|
||||||
|
sequence = NODE_LABELS[code][0]
|
||||||
|
if shipment not in progress or sequence > progress[shipment][0]:
|
||||||
|
progress[shipment] = (sequence, code, fact)
|
||||||
|
|
||||||
|
def label_shipment(shipment, fact):
|
||||||
|
if not multiple or not shipment:
|
||||||
|
return ''
|
||||||
|
return '货柜 ' + str(fact.get('shipment_no') or (shipments.index(shipment) + 1))
|
||||||
|
|
||||||
|
def node(shipment, code, value, forecast=False):
|
||||||
|
_, _, when, fact = value
|
||||||
|
place = str(fact.get('place') or '')
|
||||||
|
scope = label_shipment(shipment, fact)
|
||||||
|
return {'time': str(fact['estimated_at' if forecast else 'actual_at']),
|
||||||
|
'text': NODE_LABELS[code][2 if forecast else 1],
|
||||||
|
'place': ' · '.join(part for part in (scope, place) if part)}
|
||||||
|
|
||||||
|
nodes = [node(shipment, code, value) for (shipment, code), value in
|
||||||
|
sorted(actual.items(), key=lambda item: (item[1][2], NODE_LABELS[item[0][1]][0]), reverse=True)]
|
||||||
|
forecasts = [node(shipment, code, value, True) for (shipment, code), value in
|
||||||
|
sorted(estimated.items(), key=lambda item: item[1][2])
|
||||||
|
if (shipment, code) not in actual and NODE_LABELS[code][0] > progress.get(shipment, (-1,))[0]]
|
||||||
|
# Multiple containers keep independent progress rather than claiming the whole order advanced.
|
||||||
|
current = [(shipment, value) for shipment, value in sorted(progress.items()) if shipment or not shipments]
|
||||||
|
if not current and '' in progress:
|
||||||
|
current = [('', progress[''])]
|
||||||
|
statuses = [':'.join(part for part in (label_shipment(shipment, value[2]), NODE_LABELS[value[1]][1]) if part)
|
||||||
|
for shipment, value in current]
|
||||||
|
return {'nodes': nodes[:50], 'estimated_nodes': forecasts[:20], 'status': ';'.join(statuses)}
|
||||||
+47
-25
@@ -95,35 +95,50 @@ RATES = {
|
|||||||
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
|
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
|
||||||
|
|
||||||
def parse_dims(t):
|
def parse_dims(t):
|
||||||
"""解析 长x宽x高,例如 120*100*100 / 100x80x60 / 120×100×100
|
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
|
||||||
|
|
||||||
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
|
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
|
||||||
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
|
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
|
||||||
"""
|
"""
|
||||||
for m in re.finditer(r"(\d+)[*xX×](\d+)[*xX×](\d+)", t):
|
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
|
||||||
# 逐段向后延长,保证拿到的是完整数字而不是半截
|
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
|
||||||
dims = []
|
return [float(value) for value in match.groups()] if match else None
|
||||||
ok = True
|
|
||||||
pos = m.start()
|
|
||||||
for g in (1, 2, 3):
|
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
|
||||||
end = m.end(g)
|
('unsupported', r'美国|加拿大|澳大利亚|澳洲|新西兰|日本|韩国|新加坡|马来西亚|泰国|越南|印度|阿联酋|迪拜|沙特|巴西|墨西哥|俄罗斯|'
|
||||||
# 若后面还紧跟数字,说明这一组没取全,放弃这个候选
|
r'(?<![A-Za-z0-9_])(?-i:US)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||||
if end < len(t) and t[end].isdigit():
|
r'(?<![A-Za-z0-9_])(?:USA|United States|America|Canada|Australia|Japan|Singapore|Russia)(?![A-Za-z])'),
|
||||||
ok = False
|
('uk', r'英国|伦敦|英格兰|(?<![A-Za-z0-9_])(?:UK|GB)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||||
break
|
r'(?<![A-Za-z0-9_])(?:United Kingdom|Britain)(?![A-Za-z])'),
|
||||||
# 若是第一组,前面不能紧挨数字
|
('eu', r'欧洲|欧盟|德国|法国|意大利|西班牙|波兰|捷克|荷兰|比利时|奥地利|葡萄牙|瑞典|丹麦|芬兰|爱尔兰|'
|
||||||
if g == 1 and m.start(1) > 0 and t[m.start(1) - 1].isdigit():
|
r'(?<![A-Za-z0-9_])(?:Europe|EU|Germany|France|Italy|Spain|Poland|Netherlands)(?![A-Za-z])|'
|
||||||
ok = False
|
r'(?<![A-Za-z0-9_])(?-i:DE|FR|PL|CZ|ES|NL)(?=\s*(?:仓|FBA|warehouse))'),
|
||||||
break
|
('unsupported', r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|\bto\s+us\b'),
|
||||||
dims.append(int(m.group(g)))
|
))
|
||||||
if ok:
|
|
||||||
return [float(x) for x in dims]
|
|
||||||
return None
|
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):
|
def parse_shipping_intent(text):
|
||||||
"""从话里抠出:重量(kg)、渠道、长宽高(cm)"""
|
"""解析重量、渠道、尺寸和费率区域;尺寸单位由调用方识别。"""
|
||||||
t = text.replace(" ", "")
|
t = text.replace(" ", "")
|
||||||
intent = {"weight": None, "channel": None, "dims": None}
|
intent = {"weight": None, "channel": None, "dims": None, "region": shipping_region(text)}
|
||||||
|
|
||||||
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
|
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
|
||||||
intent["dims"] = parse_dims(text)
|
intent["dims"] = parse_dims(text)
|
||||||
@@ -192,17 +207,21 @@ def pick_rate(weight_kg, tiers):
|
|||||||
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
|
def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
|
||||||
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
|
"""海外仓卡派:按方(CBM)计费,1CBM+ 和 0.5CBM+ 两档
|
||||||
|
|
||||||
country 传入国家代码(DE/FR/PL/CZ/ES)时,优先选择包含该后缀的仓点组。
|
country 传入国家代码(DE/FR/PL/CZ/ES)时,仅选择包含该后缀的仓点组;无匹配不报价。
|
||||||
"""
|
"""
|
||||||
if not RX or not RX.WAREHOUSE_CARD:
|
if not RX or not RX.WAREHOUSE_CARD:
|
||||||
return None
|
return None
|
||||||
keys = list(RX.WAREHOUSE_CARD)
|
keys = list(RX.WAREHOUSE_CARD)
|
||||||
key = warehouse_key
|
key = warehouse_key
|
||||||
|
if key and key not in RX.WAREHOUSE_CARD:
|
||||||
|
return None
|
||||||
if not key and country:
|
if not key and country:
|
||||||
for k in keys:
|
for k in keys:
|
||||||
if ("-" + country) in k.upper():
|
if ("-" + country) in k.upper():
|
||||||
key = k
|
key = k
|
||||||
break
|
break
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
if not key:
|
if not key:
|
||||||
key = keys[0]
|
key = keys[0]
|
||||||
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
|
row = RX.WAREHOUSE_CARD.get(key) or RX.WAREHOUSE_CARD[keys[0]]
|
||||||
@@ -213,7 +232,9 @@ def calc_warehouse_card(cbm, channel, warehouse_key=None, country=None):
|
|||||||
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
|
tier = "1CBM+" if cbm >= 1 else "0.5CBM+"
|
||||||
stores = [s for s in key.splitlines() if s.strip()]
|
stores = [s for s in key.splitlines() if s.strip()]
|
||||||
if country:
|
if country:
|
||||||
stores = [s for s in stores if ("-" + country) in s.upper()] or stores
|
stores = [s for s in stores if ("-" + country) in s.upper()]
|
||||||
|
if not stores:
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
stores = stores[:1]
|
stores = stores[:1]
|
||||||
note = "报价表中的仓点分组:" + "、".join(stores)
|
note = "报价表中的仓点分组:" + "、".join(stores)
|
||||||
@@ -315,7 +336,8 @@ def freight_reply(intent):
|
|||||||
dims = intent["dims"]
|
dims = intent["dims"]
|
||||||
unit = intent.get("dim_unit") or "cm"
|
unit = intent.get("dim_unit") or "cm"
|
||||||
lines = []
|
lines = []
|
||||||
channels = [intent["channel"]] if intent["channel"] else ["sea", "air"]
|
default_channels = ['sea_uk', 'air_uk'] if intent.get('region') == 'uk' else ['sea', 'air']
|
||||||
|
channels = [intent["channel"]] if intent["channel"] else default_channels
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
c = calc_freight(w, ch, dims, unit)
|
c = calc_freight(w, ch, dims, unit)
|
||||||
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
|
parts = [f"实重 {w:g} kg" if w else "实重:未提供"]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Answer business questions from the active local book, without old regional FAQs."""
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .quote_fees import document, amount, material_fee, limit_fees
|
||||||
|
|
||||||
|
|
||||||
|
def answer_business(intent,book):
|
||||||
|
from .quote_engine import number
|
||||||
|
|
||||||
|
text=intent['text'];mode=intent.get('mode')
|
||||||
|
if mode=='currency':
|
||||||
|
return '当前海卡、海派运价中的“元/KG”和“元/CBM”按人民币(RMB/CNY)计;一件代发等资料中标注“$”“USD”“美金”的费用按美元计。不同币种分别列明,未提供结算汇率时不自动换算。'
|
||||||
|
if mode=='insurance':
|
||||||
|
raw=document(book,('保险费','投保金额'))
|
||||||
|
percent=amount(raw,r'百分之\s*(\d+(?:\.\d+)?)')
|
||||||
|
minimum=amount(raw,r'min\s*(\d+(?:\.\d+)?)\s*USD')
|
||||||
|
multiplier=amount(raw,r'货值乘以\s*(\d+(?:\.\d+)?)')
|
||||||
|
if percent is None or minimum is None or multiplier is None:
|
||||||
|
return '当前资料未明确保险的完整计费条件,请提供货值和币种,由人工确认保险费。'
|
||||||
|
lines=[f'当前资料保险费率为保额的{percent:g}%,最低{minimum:g}USD;按货值投保时,投保金额为货值×{multiplier:g}。']
|
||||||
|
money=re.search(r'(-?\d+(?:\.\d+)?)\s*(万)?\s*(美元|美金|USD|人民币|元)',text,re.I)
|
||||||
|
if money and money.group(3).lower() in ('美元','美金','usd'):
|
||||||
|
value=Decimal(money.group(1))*(10000 if money.group(2) else 1)
|
||||||
|
if not 0<value<=1000000000:return '请提供大于0的有效货值或保额及币种。'
|
||||||
|
insured=value if re.search(r'保价|保额',text) and not re.search(r'货值',text) else value*multiplier
|
||||||
|
total=max(insured*percent/100,minimum).quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
|
||||||
|
lines.append(f'按保额{number(insured)}USD计算:max({number(insured)}×{percent:g}%,{minimum:g})={number(total)}USD。')
|
||||||
|
else:
|
||||||
|
lines.append('请提供货值/保额及币种;人民币货值还需确认适用结算汇率。')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
if mode=='fulfillment':
|
||||||
|
tokens=[t for t in ('20GP','40GP','45HQ','散箱','散托','贴标','仓储','仓租','分拣','SKU') if t.lower() in text.lower()]
|
||||||
|
if '仓租' in tokens:tokens.append('仓储')
|
||||||
|
if '卸货' in text and not tokens:tokens=['20GP','40GP','45HQ']
|
||||||
|
rows=[]
|
||||||
|
for d in book['documents']:
|
||||||
|
if d['sheet']!='一件代发收费' or not re.search(r'\$|USD|美金',d['content'],re.I):continue
|
||||||
|
score=sum(t.lower() in d['content'].lower() for t in tokens) if tokens else 1
|
||||||
|
if score:rows.append((score,d['content']))
|
||||||
|
rows=sorted(rows,key=lambda row:(-row[0],-(';' in row[1])))
|
||||||
|
facts=list(dict.fromkeys(row[1] for row in rows))[:4]
|
||||||
|
return '美国仓一件代发收费:\n'+'\n'.join('- '+r for r in facts) if facts else '当前资料中未匹配到该一件代发收费项,请补充服务项目、箱数或体积,由人工核实。'
|
||||||
|
if mode=='customs':
|
||||||
|
if intent.get('services') and '美森加班' in intent['services'] and document(book,('不可接一般贸易',),'美森加班海卡'):
|
||||||
|
return '当前资料中美森加班海卡不可接一般贸易,请由人工确认其他渠道,不能直接按该线路加报关费办理。'
|
||||||
|
if re.search(r'FDA',text,re.I):
|
||||||
|
raw=document(book,('FDA','申报费'))
|
||||||
|
fee=amount(raw,r'申报费[::]?\s*RMB\s*(\d+(?:\.\d+)?)')
|
||||||
|
if fee is not None:return f'当前资料中需做FDA Filing等申报的产品,申报费为{fee:g}人民币/票。请补充具体品名和渠道,确认是否接收及需要的资料。'
|
||||||
|
return '当前资料未匹配到FDA申报费,请提供具体品名和渠道,由人工确认是否接收及费用。'
|
||||||
|
raw=document(book,('海外进口清关','续页'))
|
||||||
|
extra=amount(raw,r'加收USD\s*(\d+(?:\.\d+)?)')
|
||||||
|
lines=[]
|
||||||
|
if re.search(r'续页|品名',text) and extra is not None:
|
||||||
|
lines.append(f'海外进口清关:每票超过5个品名的部分,按{extra:g}美元/个收取。')
|
||||||
|
count=re.search(r'(\d+)\s*个品名',text)
|
||||||
|
if count:lines.append(f'{count.group(1)}个品名的该项费用:max({count.group(1)}−5,0)×{extra:g}={max(int(count.group(1))-5,0)*extra:g}美元。')
|
||||||
|
lines.append('国内出口报关续项与海外进口清关分别收费;国内条款的品名计数方式需按票确认。')
|
||||||
|
raw_fee=document(book,('一般贸易出口报关费',))
|
||||||
|
declaration=amount(raw_fee,r'出口报关费[::]\s*RMB\s*(\d+(?:\.\d+)?)')
|
||||||
|
if declaration is not None:lines.append(f'一般贸易出口报关费为{declaration:g}人民币/票。')
|
||||||
|
if re.search(r'单独报关',text):
|
||||||
|
minimum=document(book,('单独报关货物','300KG'))
|
||||||
|
if minimum:lines.append('单独报关货物最低按300KG计费;还需确认所选渠道是否接受一般贸易。')
|
||||||
|
return '\n'.join(lines) or '请提供具体申报项目、品名数量及渠道,当前资料无法确定该项费用。'
|
||||||
|
if mode=='acceptance':
|
||||||
|
raw=document(book,('禁止运输的货物',))
|
||||||
|
if raw:
|
||||||
|
prohibited=next((t for t in ('纯电池','液体','粉末','食品','医疗用品','仿牌') if t in text),None)
|
||||||
|
if prohibited:return f'当前海派资料将{prohibited}列为不接收/禁止运输的类别,不能按普通货直接报价。若咨询其他渠道,请先由人工核实承运条件。'
|
||||||
|
if re.search(r'带电|电池',text):return '当前海派资料中内置电池需要提供产品图片、MSDS及海运运输鉴定报告后单独确认;纯电池不接收。请补充电池类型和产品信息,确认后再匹配运价。'
|
||||||
|
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
|
||||||
|
if mode=='fees':
|
||||||
|
lines=[]
|
||||||
|
metal=material_fee(book)
|
||||||
|
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
|
||||||
|
lines.append(f'当前资料铁铝制品附加费为{metal:g}元/KG。')
|
||||||
|
if intent.get('weight'):lines.append(f'先按已提供重量{number(intent["weight"])}KG计算该项:{number(intent["weight"])}×{metal:g}={number(Decimal(intent["weight"])*metal)}元;计费重确定后继续核算。')
|
||||||
|
if re.search(r'超重|超尺寸|超长',text):
|
||||||
|
raw=document(book,('超重收',))
|
||||||
|
fee=amount(raw,r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
|
||||||
|
if fee is not None:lines.append(f'海派明确的普通超重附加费为{fee:g}元/件;同箱同时超重、超尺寸时只收适用的最高一项。')
|
||||||
|
limits=limit_fees(intent,book)
|
||||||
|
lines+=limits['items']+limits['notes']
|
||||||
|
if re.search(r'住宅|私人|商业地址|非亚马逊',text):
|
||||||
|
lines.append('住宅或商业等非亚马逊地址的派送附加费在原表中需单独询价;请提供完整邮编、地址类型和箱规,不能直接当作FBA仓价格。')
|
||||||
|
if not lines or re.search(r'包含|包括|还[要需]|什么费用',text):
|
||||||
|
raw=document(book,('偏远地址加收','超偏远'))
|
||||||
|
remote=amount(raw,r'(?<!超)偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
|
||||||
|
extended=amount(raw,r'超偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
|
||||||
|
if remote is not None and extended is not None:
|
||||||
|
lines.append(f'当前美国海派资料:偏远地址加{remote:g}元/KG,超偏远地址加{extended:g}元/KG,遥远地址单询;海派每箱不足12KG按12KG计费。')
|
||||||
|
lines.append('报价中的包税/不包税是不同费率口径;材质、报关申报、偏远及超重超尺寸等按条件单列,不代表所有服务费用都已包含。')
|
||||||
|
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG;多项费用分别列明,同箱超限费用只取最高项。')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Versioned, private quote imports and read-only runtime access."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
|
||||||
|
def init_quote_tables(connection):
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS quote_books (
|
||||||
|
id INTEGER PRIMARY KEY, source_sha256 TEXT NOT NULL UNIQUE,
|
||||||
|
source_name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 0)''')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS quote_rates (
|
||||||
|
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, service TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL, origin TEXT NOT NULL, zone TEXT NOT NULL,
|
||||||
|
unit TEXT NOT NULL, tax TEXT NOT NULL, min_weight TEXT NOT NULL,
|
||||||
|
price TEXT NOT NULL, eta TEXT NOT NULL, destinations_json TEXT NOT NULL,
|
||||||
|
source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_rates_book ON quote_rates(book_id)')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS quote_warehouses (
|
||||||
|
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, code TEXT NOT NULL,
|
||||||
|
zone TEXT NOT NULL, state TEXT NOT NULL, postal TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL, source_sheet TEXT NOT NULL, source_cell TEXT NOT NULL)''')
|
||||||
|
connection.execute('CREATE INDEX IF NOT EXISTS idx_quote_warehouse_code ON quote_warehouses(book_id,code)')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS quote_postcodes (
|
||||||
|
book_id INTEGER NOT NULL, postal TEXT NOT NULL, severity TEXT NOT NULL,
|
||||||
|
fee TEXT, PRIMARY KEY(book_id,postal))''')
|
||||||
|
connection.execute('''CREATE TABLE IF NOT EXISTS quote_documents (
|
||||||
|
id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL, sheet TEXT NOT NULL,
|
||||||
|
source_cell TEXT NOT NULL, content TEXT NOT NULL)''')
|
||||||
|
|
||||||
|
|
||||||
|
def decimal_text(value, positive=False):
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
raise ValueError('Invalid quote amount') from None
|
||||||
|
if not number.is_finite() or number < 0 or number > 100000000 or (positive and not number):
|
||||||
|
raise ValueError('Invalid quote amount')
|
||||||
|
return format(number, 'f')
|
||||||
|
|
||||||
|
|
||||||
|
def import_snapshot(connection, snapshot, *, activate=False):
|
||||||
|
"""Validate before writing; replace an active book atomically, never order data."""
|
||||||
|
if not isinstance(snapshot, dict) or snapshot.get('schema') != 1:
|
||||||
|
raise ValueError('Unsupported quote snapshot')
|
||||||
|
digest = snapshot.get('source_sha256', '')
|
||||||
|
if not re.fullmatch('[0-9a-f]{64}', digest):
|
||||||
|
raise ValueError('Invalid quote source checksum')
|
||||||
|
source_name = str(snapshot.get('source_name', ''))[:240]
|
||||||
|
if not source_name:
|
||||||
|
raise ValueError('Missing quote source name')
|
||||||
|
limits = {'rates':10000, 'warehouses':10000, 'postcodes':50000, 'documents':5000}
|
||||||
|
for key, limit in limits.items():
|
||||||
|
if not isinstance(snapshot.get(key), list) or len(snapshot[key]) > limit:
|
||||||
|
raise ValueError('Invalid quote records')
|
||||||
|
rates = []
|
||||||
|
for row in snapshot['rates']:
|
||||||
|
if (row.get('kind') not in ('truck', 'parcel') or row.get('unit') not in ('kg', 'cbm')
|
||||||
|
or row.get('tax') not in ('included', 'excluded') or not row.get('service')
|
||||||
|
or not isinstance(row.get('destinations'), list)
|
||||||
|
or len(row['destinations']) > 200):
|
||||||
|
raise ValueError('Invalid quote rate')
|
||||||
|
rates.append((str(row['service'])[:100], row['kind'], str(row.get('origin', ''))[:40],
|
||||||
|
str(row.get('zone', ''))[:40], row['unit'], row['tax'],
|
||||||
|
decimal_text(row.get('min_weight', 0)), decimal_text(row['price'], positive=True),
|
||||||
|
str(row.get('eta', ''))[:200], json.dumps(row['destinations'], ensure_ascii=False),
|
||||||
|
str(row['source_sheet'])[:100], str(row['source_cell'])[:20]))
|
||||||
|
warehouses = [(str(r.get('code', '')).strip().upper()[:40], str(r.get('zone', ''))[:40],
|
||||||
|
str(r.get('state', ''))[:40], str(r.get('postal', ''))[:20], str(r.get('address', ''))[:1000],
|
||||||
|
str(r['source_sheet'])[:100], str(r['source_cell'])[:20]) for r in snapshot['warehouses']]
|
||||||
|
postcodes = []
|
||||||
|
for row in snapshot['postcodes']:
|
||||||
|
if not re.fullmatch('[0-9]{5}', row.get('postal', '')) or row.get('severity') not in ('remote','extended','extreme'):
|
||||||
|
raise ValueError('Invalid quote postcode')
|
||||||
|
postcodes.append((row['postal'], row['severity'], None if row.get('fee') is None else decimal_text(row['fee'])))
|
||||||
|
documents = [(str(r['sheet'])[:100], str(r['cell'])[:20], str(r['text'])[:16000]) for r in snapshot['documents']]
|
||||||
|
if not rates:
|
||||||
|
raise ValueError('Quote snapshot contains no rates')
|
||||||
|
init_quote_tables(connection)
|
||||||
|
if not connection.in_transaction:
|
||||||
|
connection.execute('BEGIN IMMEDIATE')
|
||||||
|
connection.execute('SAVEPOINT quote_import')
|
||||||
|
try:
|
||||||
|
existing = connection.execute('SELECT id FROM quote_books WHERE source_sha256=?', (digest,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
book_id = existing[0]
|
||||||
|
else:
|
||||||
|
book_id = connection.execute('INSERT INTO quote_books(source_sha256,source_name) VALUES(?,?)',
|
||||||
|
(digest, source_name)).lastrowid
|
||||||
|
connection.executemany('''INSERT INTO quote_rates(book_id,service,kind,origin,zone,unit,tax,
|
||||||
|
min_weight,price,eta,destinations_json,source_sheet,source_cell) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)''',
|
||||||
|
[(book_id, *r) for r in rates])
|
||||||
|
connection.executemany('''INSERT INTO quote_warehouses(book_id,code,zone,state,postal,address,source_sheet,source_cell)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?)''', [(book_id, *r) for r in warehouses])
|
||||||
|
connection.executemany('INSERT INTO quote_postcodes(book_id,postal,severity,fee) VALUES(?,?,?,?)',
|
||||||
|
[(book_id, *r) for r in postcodes])
|
||||||
|
connection.executemany('INSERT INTO quote_documents(book_id,sheet,source_cell,content) VALUES(?,?,?,?)',
|
||||||
|
[(book_id, *r) for r in documents])
|
||||||
|
if activate:
|
||||||
|
connection.execute('UPDATE quote_books SET active=0 WHERE active<>0')
|
||||||
|
connection.execute('UPDATE quote_books SET active=1 WHERE id=?', (book_id,))
|
||||||
|
connection.execute('RELEASE quote_import')
|
||||||
|
except Exception:
|
||||||
|
connection.execute('ROLLBACK TO quote_import')
|
||||||
|
connection.execute('RELEASE quote_import')
|
||||||
|
raise
|
||||||
|
return {'book_id':book_id, 'rates':len(rates), 'warehouses':len(warehouses),
|
||||||
|
'postcodes':len(postcodes), 'documents':len(documents), 'active':bool(activate), 'existing':bool(existing)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_book():
|
||||||
|
"""One read transaction prevents mixed versions during a concurrent import."""
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute('BEGIN')
|
||||||
|
book = connection.execute('SELECT id FROM quote_books WHERE active=1 ORDER BY id DESC LIMIT 1').fetchone()
|
||||||
|
if not book:
|
||||||
|
return None
|
||||||
|
book_id = book['id']
|
||||||
|
result = {'id':book_id}
|
||||||
|
for table, name in (('quote_rates','rates'), ('quote_warehouses','warehouses'), ('quote_documents','documents')):
|
||||||
|
result[name] = [dict(r) for r in connection.execute(f'SELECT * FROM {table} WHERE book_id=?', (book_id,))]
|
||||||
|
for rate in result['rates']:
|
||||||
|
rate['destinations'] = json.loads(rate.pop('destinations_json'))
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def postcode_fee(book_id, postal):
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
row = connection.execute('SELECT severity,fee FROM quote_postcodes WHERE book_id=? AND postal=?',
|
||||||
|
(book_id, postal)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .pricing import dims_to_cm
|
||||||
|
|
||||||
|
|
||||||
|
from .quote_intent import US, QUOTE_WORDS, parse_intent, is_followup
|
||||||
|
|
||||||
|
|
||||||
|
def broad_zone(postal):
|
||||||
|
if not postal:
|
||||||
|
return None
|
||||||
|
if postal.startswith(('8','9')):
|
||||||
|
return '美西'
|
||||||
|
if postal.startswith('46') or postal[0] in '0123':
|
||||||
|
return '美东'
|
||||||
|
return '美中'
|
||||||
|
|
||||||
|
|
||||||
|
def variants(intent, book):
|
||||||
|
if len(intent.get('warehouses') or [])>1:
|
||||||
|
return [variant for warehouse in intent['warehouses'] for variant in
|
||||||
|
variants({**intent,'warehouse':warehouse,'warehouses':None,'postal':None},book)]
|
||||||
|
if not intent['warehouse'] and len(intent.get('zones') or [])>1:
|
||||||
|
return [variant for zone in intent['zones'] for variant in
|
||||||
|
variants({**intent,'zone':zone,'zones':None},book)]
|
||||||
|
warehouse = intent['warehouse']
|
||||||
|
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
|
||||||
|
zoned = [r for r in found if r['zone']]
|
||||||
|
if zoned:
|
||||||
|
unique = {}
|
||||||
|
for row in zoned:
|
||||||
|
postal = intent['postal'] or row['postal'][:5]
|
||||||
|
key = (row['zone'], postal)
|
||||||
|
unique.setdefault(key, {'label':f"{warehouse} · {row['zone']}", 'postal':postal,
|
||||||
|
'zone':broad_zone(postal), 'warehouse':warehouse})
|
||||||
|
return list(unique.values())
|
||||||
|
postal = intent['postal']
|
||||||
|
if not postal and found:
|
||||||
|
match = re.search(r'(?<![0-9])([0-9]{5})(?:-[0-9]{4})?(?![0-9])', found[0]['address'])
|
||||||
|
postal = match.group(1) if match else None
|
||||||
|
zone = intent['zone']
|
||||||
|
if zone and zone[0] in '东中西':
|
||||||
|
zone = {'东':'美东','中':'美中','西':'美西'}[zone[0]]
|
||||||
|
return [{'label':warehouse or postal or zone or '美国', 'postal':postal, 'zone':zone or broad_zone(postal), 'warehouse':warehouse}]
|
||||||
|
|
||||||
|
|
||||||
|
def quantity(intent, rate):
|
||||||
|
real = Decimal(intent['weight'] or '0')
|
||||||
|
volume = Decimal(intent['cbm']) if intent['cbm'] else None
|
||||||
|
if volume is None and intent['dims']:
|
||||||
|
cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])]
|
||||||
|
volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1)
|
||||||
|
package_charge=Decimal(0)
|
||||||
|
if intent.get('packages'):
|
||||||
|
package_volume=Decimal(0)
|
||||||
|
all_dimensions=True
|
||||||
|
for package in intent['packages']:
|
||||||
|
dims=package.get('dims') or intent['dims']
|
||||||
|
cm=[Decimal(str(value)) for value in dims_to_cm(dims,package.get('dim_unit') or intent['dim_unit'])] if dims else None
|
||||||
|
box_volume=cm[0]*cm[1]*cm[2]/1000000 if cm else Decimal(0)
|
||||||
|
all_dimensions=all_dimensions and bool(cm)
|
||||||
|
package_volume+=box_volume*package['count']
|
||||||
|
piece=max(Decimal(package['weight']),box_volume*1000000/6000 if rate['kind']=='parcel' else box_volume*167)
|
||||||
|
package_charge+=max(piece,Decimal(12) if rate['kind']=='parcel' else Decimal(0))*package['count']
|
||||||
|
if all_dimensions and not intent['cbm']:volume=package_volume
|
||||||
|
if rate['unit'] == 'cbm':
|
||||||
|
if volume is None:
|
||||||
|
return None
|
||||||
|
result=max(volume, real/Decimal(363))
|
||||||
|
if rate['service']=='洛杉矶普船海卡':
|
||||||
|
result=result.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
|
||||||
|
return result if result>0 else None
|
||||||
|
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
|
||||||
|
else (volume or Decimal(0))*167)
|
||||||
|
charge = max(real, volume_weight,package_charge)
|
||||||
|
if rate['kind'] == 'parcel':
|
||||||
|
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
|
||||||
|
return charge if real or volume else None
|
||||||
|
|
||||||
|
|
||||||
|
def match_rates(intent, book, variant):
|
||||||
|
selected = []
|
||||||
|
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
|
||||||
|
for rate in book['rates']:
|
||||||
|
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
|
||||||
|
continue
|
||||||
|
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
|
||||||
|
continue
|
||||||
|
if intent['tax'] and rate['tax'] != intent['tax']:
|
||||||
|
continue
|
||||||
|
if rate['kind'] == 'truck':
|
||||||
|
if not variant['warehouse'] or variant['warehouse'] not in [value.upper() for value in rate['destinations']]:
|
||||||
|
continue
|
||||||
|
elif variant['zone'] and rate['zone'] != variant['zone']:
|
||||||
|
continue
|
||||||
|
selected.append(rate)
|
||||||
|
groups = {}
|
||||||
|
for rate in selected:
|
||||||
|
key = (rate['service'],rate['origin'],rate['zone'],rate['unit'],rate['tax'])
|
||||||
|
charge = quantity(intent,rate)
|
||||||
|
if rate['kind'] == 'parcel' and Decimal(rate['min_weight']) > (charge or Decimal(12)):
|
||||||
|
continue
|
||||||
|
if key not in groups or Decimal(rate['min_weight']) > Decimal(groups[key]['min_weight']):
|
||||||
|
groups[key] = rate
|
||||||
|
return list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
def number(value, places=2):
|
||||||
|
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def render_rates(intent, book, destinations, remote_fees):
|
||||||
|
from .quote_fees import charge_details
|
||||||
|
from .quote_language import translate_quote
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
if intent.get('mode')=='time':
|
||||||
|
lines.append('美国线路参考时效:')
|
||||||
|
elif intent['weight']:
|
||||||
|
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
|
||||||
|
else:
|
||||||
|
lines.append('美国线路报价:')
|
||||||
|
if len(intent.get('warehouses') or [])>1:
|
||||||
|
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
|
||||||
|
if intent.get('air_requested') and not intent.get('air_only'):
|
||||||
|
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
|
||||||
|
for destination, fees in zip(destinations, remote_fees):
|
||||||
|
rates = match_rates(intent, book, destination)
|
||||||
|
if intent.get('mode') in ('cheapest','fastest') and rates:
|
||||||
|
if intent['mode']=='cheapest':
|
||||||
|
eligible=[r for r in rates if quantity(intent,r) is not None]
|
||||||
|
best={}
|
||||||
|
for rate in eligible:
|
||||||
|
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||||
|
value=quantity(intent,rate)*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
|
||||||
|
best[key]=min(best.get(key,value),value)
|
||||||
|
rates=[r for r in eligible if quantity(intent,r)*Decimal(r['price'])+charge_details(intent,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||||
|
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||||
|
else:
|
||||||
|
def eta_key(rate):
|
||||||
|
values=[Decimal(v) for v in re.findall(r'\d+(?:\.\d+)?',rate['eta'])]
|
||||||
|
return tuple(values[:2]) if values else (Decimal('Infinity'),)
|
||||||
|
earliest=min(eta_key(r) for r in rates)
|
||||||
|
rates=[r for r in rates if eta_key(r)==earliest]
|
||||||
|
lines.append('按资料中的参考时效,较快方案如下;提取不等于送达或签收:')
|
||||||
|
if not rates:
|
||||||
|
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||||||
|
continue
|
||||||
|
if destination['label'] != '美国':
|
||||||
|
lines.append(destination['label']+':')
|
||||||
|
grouped = {}
|
||||||
|
for rate in rates:
|
||||||
|
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
|
||||||
|
grouped.setdefault(key, []).append(rate)
|
||||||
|
for (service, origin, unit, tax), rows in grouped.items():
|
||||||
|
if intent.get('mode')=='time':
|
||||||
|
eta=rows[0]['eta'] or '当前资料未列明时效'
|
||||||
|
if rows[0]['kind']=='parcel' and '提取' not in eta:eta+='提取'
|
||||||
|
lines.append(f'- {service} · {origin}:{eta}。提取不等于送达或签收。')
|
||||||
|
continue
|
||||||
|
parts = []
|
||||||
|
for rate in rows:
|
||||||
|
amount = quantity(intent,rate)
|
||||||
|
price = Decimal(rate['price'])
|
||||||
|
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
|
||||||
|
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
|
||||||
|
if amount is not None:
|
||||||
|
total = amount*price
|
||||||
|
details=charge_details(intent,rate,fees,book)
|
||||||
|
total+=details['cny']
|
||||||
|
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
|
||||||
|
if details['items']:rate_text+='('+ ';'.join(details['items'])+')'
|
||||||
|
for note in details['notes']:
|
||||||
|
if note not in lines:lines.append(note)
|
||||||
|
elif intent.get('cbm') and rate['unit']=='cbm':
|
||||||
|
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
|
||||||
|
parts.append(rate_text)
|
||||||
|
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
|
||||||
|
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
|
||||||
|
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
|
||||||
|
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||||||
|
+ (f'({eta})' if eta else ''))
|
||||||
|
if fees:
|
||||||
|
if fees['fee'] is None:
|
||||||
|
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
|
||||||
|
else:
|
||||||
|
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
|
||||||
|
if not any(intent.get(key) for key in ('warehouse','postal')):
|
||||||
|
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
|
||||||
|
if intent.get('mode')=='time':
|
||||||
|
pass
|
||||||
|
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
|
||||||
|
lines.append('货物多少公斤、几箱?')
|
||||||
|
elif not intent['boxes'] or not intent['dims']:
|
||||||
|
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
|
||||||
|
if intent.get('mode')=='calculation':
|
||||||
|
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
|
||||||
|
text='\n'.join(lines)
|
||||||
|
return translate_quote(text) if intent.get('english') else text
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Amounts come from the active book; explicit customer-approved fee policies live here."""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .pricing import dims_to_cm
|
||||||
|
|
||||||
|
|
||||||
|
def document(book,required,service=None):
|
||||||
|
rows=[d for d in book['documents'] if (not service or d['sheet'] in (service,'海派'))
|
||||||
|
and all(term in d['content'] for term in required)]
|
||||||
|
return rows[0]['content'] if rows else ''
|
||||||
|
|
||||||
|
|
||||||
|
def amount(text,pattern):
|
||||||
|
match=re.search(pattern,text,re.I)
|
||||||
|
return Decimal(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def material_fee(book):
|
||||||
|
text=document(book,('铁铝','附加费'))
|
||||||
|
return amount(text,r'铁铝制品[^\n]*?[¥¥]\s*(\d+(?:\.\d+)?)\s*/KG')
|
||||||
|
|
||||||
|
|
||||||
|
def specimens(intent):
|
||||||
|
if intent.get('packages'):
|
||||||
|
return [{**p,'dims':p.get('dims') or intent.get('dims'),
|
||||||
|
'dim_unit':p.get('dim_unit') if p.get('dims') else intent.get('dim_unit','cm')} for p in intent['packages']]
|
||||||
|
if intent.get('boxes')==1 and intent.get('weight'):
|
||||||
|
return [{'count':1,'weight':intent['weight'],'dims':intent.get('dims'),'dim_unit':intent.get('dim_unit','cm')}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def limit_fees(intent,book):
|
||||||
|
"""Maximum per-piece limit fee, not the sum of overlapping weight/size charges.
|
||||||
|
|
||||||
|
The malformed 0<length<274 rule does not create a new threshold. Only two
|
||||||
|
independently explicit large-piece conditions can establish that tier.
|
||||||
|
"""
|
||||||
|
normal=document(book,('正常货物',))
|
||||||
|
cleaned=re.sub(r'\s+','',normal).replace('<','<').upper()
|
||||||
|
limits=[amount(cleaned,p) for p in (r'0KG<单件重量<(\d+)KG',r'0CM<最长单边长度<(\d+)CM',
|
||||||
|
r'0CM<第二边<(\d+)CM',r'最长边<(\d+)CM')]
|
||||||
|
normal_fee=amount(document(book,('超重收',)),r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
|
||||||
|
size_text=document(book,('普通超尺寸',))
|
||||||
|
size_fee=amount(size_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
|
||||||
|
large_text=document(book,('大尺寸货物','任意2个'))
|
||||||
|
large_fee=amount(large_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
|
||||||
|
huge=document(book,('超大件货物',))
|
||||||
|
huge_fee=amount(huge,r'加收\s*USD\s*(\d+(?:\.\d+)?)')
|
||||||
|
result={'cny':Decimal(0),'items':[],'notes':[]}
|
||||||
|
if not all(limits) or normal_fee is None:return result
|
||||||
|
max_weight,max_length,max_second,max_girth=limits
|
||||||
|
for piece in specimens(intent):
|
||||||
|
weight=Decimal(piece['weight']);count=piece['count']
|
||||||
|
dims=sorted([Decimal(str(x)) for x in dims_to_cm(piece['dims'],piece['dim_unit'])],reverse=True) if piece['dims'] else []
|
||||||
|
length,second,girth=(dims[0],dims[1],dims[0]+2*(dims[1]+dims[2])) if dims else (None,None,None)
|
||||||
|
if weight>68 or (dims and (length>270 or girth>419)):
|
||||||
|
result['notes'].append('该件可能超出承运尺寸或重量,需人工确认是否接收。'+(f'原表超大件若接收的附加费为{huge_fee:g}USD/件。' if huge_fee else ''))
|
||||||
|
continue
|
||||||
|
candidates=[]
|
||||||
|
if weight>max_weight:candidates.append(normal_fee)
|
||||||
|
if dims and (length>max_length or second>max_second or girth>max_girth) and size_fee is not None:candidates.append(size_fee)
|
||||||
|
if large_fee is not None and max_weight<weight<68 and dims and 330<girth<419:
|
||||||
|
candidates.append(large_fee)
|
||||||
|
elif dims and (length>=243 or girth>=330):
|
||||||
|
result['notes'].append('该件接近大尺寸档,原表有一处长度条件不清,适用档位需人工确认。')
|
||||||
|
if candidates:
|
||||||
|
fee=max(candidates)*count
|
||||||
|
result['cny']+=fee
|
||||||
|
result['items'].append(f'超重/超尺寸按最高项{max(candidates):g}元/件×{count}件={fee:g}元')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def charge_details(intent,rate,remote,book):
|
||||||
|
from .quote_engine import quantity
|
||||||
|
|
||||||
|
result={'cny':Decimal(0),'items':[],'notes':[]}
|
||||||
|
charged=quantity(intent,rate)
|
||||||
|
if charged is None:return result
|
||||||
|
if rate['kind']=='parcel' and remote:
|
||||||
|
if remote['fee'] is not None:
|
||||||
|
fee=charged*Decimal(remote['fee']);result['cny']+=fee
|
||||||
|
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
|
||||||
|
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
|
||||||
|
if rate['unit']=='kg' and intent.get('material')=='metal':
|
||||||
|
per_kg=material_fee(book)
|
||||||
|
if per_kg is not None:
|
||||||
|
fee=charged*per_kg;result['cny']+=fee
|
||||||
|
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元')
|
||||||
|
if rate['kind']=='parcel':
|
||||||
|
limits=limit_fees(intent,book)
|
||||||
|
result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes']
|
||||||
|
if intent.get('address_type')=='non_fba':
|
||||||
|
result['notes'].append('非亚马逊地址还需核对住宅/商业派送附加费,原表此项需单独询价。')
|
||||||
|
return result
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Normalize customer wording into bounded quote fields without model inference."""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
import re
|
||||||
|
|
||||||
|
from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region
|
||||||
|
|
||||||
|
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
|
||||||
|
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
|
||||||
|
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
|
||||||
|
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
|
||||||
|
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
|
||||||
|
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
|
||||||
|
|
||||||
|
|
||||||
|
def chinese_number(text):
|
||||||
|
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
|
||||||
|
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
|
||||||
|
if '点' in text:
|
||||||
|
left,right=text.split('点',1)
|
||||||
|
return str(chinese_number(left or '零'))+'.'+''.join(str(digits[c]) for c in right)
|
||||||
|
total=section=number=0
|
||||||
|
for character in text:
|
||||||
|
if character in digits:
|
||||||
|
number=digits[character]
|
||||||
|
elif character=='万':
|
||||||
|
total+=(section+number)*10000;section=number=0
|
||||||
|
else:
|
||||||
|
section+=(number or 1)*{'十':10,'百':100,'千':1000}[character];number=0
|
||||||
|
return str(total+section+number)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text):
|
||||||
|
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|箱|件|立方米|立方|方|kg|cbm))',
|
||||||
|
lambda m:chinese_number(m.group(1)),text,flags=re.I)
|
||||||
|
return re.sub(r'负(?=\d)','-',result)
|
||||||
|
|
||||||
|
|
||||||
|
def kg(value,unit):
|
||||||
|
value=Decimal(value)
|
||||||
|
unit=unit.lower()
|
||||||
|
if unit in ('克','g','gram','grams'):return value/1000
|
||||||
|
if unit in ('吨','ton','tons'):return value*1000
|
||||||
|
if unit in ('磅','lb','lbs'):return (value*Decimal('0.4536')).quantize(Decimal('.01'))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def request_mode(text):
|
||||||
|
# Business topics outrank generic verbs such as “怎么算”.
|
||||||
|
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
|
||||||
|
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
|
||||||
|
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||||
|
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
|
||||||
|
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
|
||||||
|
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
|
||||||
|
if re.search(r'附加费|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
|
||||||
|
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
|
||||||
|
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
|
||||||
|
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
|
||||||
|
if re.search(r'时效|多久|几天',text) and not WEIGHT.search(text):return 'time'
|
||||||
|
return 'quote'
|
||||||
|
|
||||||
|
|
||||||
|
def parse_intent(message,warehouse_codes):
|
||||||
|
text=normalize(message)
|
||||||
|
parsed=parse_shipping_intent(text)
|
||||||
|
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
|
||||||
|
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
|
||||||
|
warehouses=list(dict.fromkeys(t for t in candidates if t in warehouse_codes))
|
||||||
|
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
|
||||||
|
and not t.startswith(('USD','RMB','CNY'))]
|
||||||
|
errors=[]
|
||||||
|
weights=[m for m in WEIGHT.finditer(text) if not text[:m.start()].rstrip().endswith(('x','X','×','*'))]
|
||||||
|
weight=str(kg(*weights[0].groups())) if weights else None
|
||||||
|
boxes=re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)',text,re.I)
|
||||||
|
count=int(boxes.group(1)) if boxes else None
|
||||||
|
packages=[]
|
||||||
|
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
|
||||||
|
matches=list(package_re.finditer(text))
|
||||||
|
for index,match in enumerate(matches):
|
||||||
|
ordinal,n,value,unit=match.groups()
|
||||||
|
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
|
||||||
|
dims=parse_shipping_intent(segment)['dims']
|
||||||
|
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
|
||||||
|
'dims':dims,'dim_unit':detect_dim_unit(segment)})
|
||||||
|
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
|
||||||
|
if each and count and not packages:
|
||||||
|
packages=[{'count':count,'weight':str(kg(*each.groups())),'dims':parsed['dims'],'dim_unit':detect_dim_unit(text)}]
|
||||||
|
if packages:
|
||||||
|
package_count=sum(p['count'] for p in packages)
|
||||||
|
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
|
||||||
|
if count and aggregate_count and count!=package_count:
|
||||||
|
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
|
||||||
|
count=package_count
|
||||||
|
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
|
||||||
|
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
|
||||||
|
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
|
||||||
|
# Per-carton dimensions must not spill onto another carton without dimensions.
|
||||||
|
if len(packages)>1:parsed['dims']=None
|
||||||
|
elif len(weights)>1:
|
||||||
|
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
|
||||||
|
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
|
||||||
|
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
|
||||||
|
postal=None
|
||||||
|
if labeled:
|
||||||
|
if re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?',labeled.group(1)):postal=labeled.group(1)[:5]
|
||||||
|
else:errors.append('美国邮编请提供5位数字,可附加4位扩展码;当前邮编格式不正确。')
|
||||||
|
else:
|
||||||
|
remaining=WEIGHT.sub(' ',text)
|
||||||
|
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
|
||||||
|
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
|
||||||
|
if match:postal=match.group(1)
|
||||||
|
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
|
||||||
|
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
|
||||||
|
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
|
||||||
|
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
|
||||||
|
if '美森' in text and not any(n.startswith('美森') for n in services):services+=['美森正班','美森加班']
|
||||||
|
kinds=[]
|
||||||
|
if re.search(r'海派|快递|UPS|FEDEX',text,re.I):kinds.append('parcel')
|
||||||
|
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路',text):kinds.append('truck')
|
||||||
|
zones=[z for z in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if z in text]
|
||||||
|
if not zones and '洛杉矶' in text:zones=['美西']
|
||||||
|
if not zones and re.search(r'纽约|萨凡纳',text):zones=['美东']
|
||||||
|
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
|
||||||
|
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
|
||||||
|
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
|
||||||
|
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
|
||||||
|
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'origin':origin,
|
||||||
|
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
|
||||||
|
'air_only':True if air and not sea else None,'air_requested':True if air else None,
|
||||||
|
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
|
||||||
|
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
|
||||||
|
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
|
||||||
|
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||||||
|
'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else
|
||||||
|
('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) else None)),
|
||||||
|
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
|
||||||
|
'text':text}
|
||||||
|
|
||||||
|
|
||||||
|
def is_followup(message,intent):
|
||||||
|
if TOPIC_BOUNDARY.search(message):return False
|
||||||
|
text=message.strip().strip(',,。.!!?? ')
|
||||||
|
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
|
||||||
|
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
|
||||||
|
if intent.get('warehouse') or intent.get('unknown_warehouses'):
|
||||||
|
return not (intent['country'] and intent['weight'])
|
||||||
|
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
|
||||||
|
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
|
||||||
|
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
|
||||||
|
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin')))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""English rendering for deterministic quotes; numerical facts remain unchanged."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def translate_quote(text):
|
||||||
|
replacements=(
|
||||||
|
('美森正班海派','Matson regular sea parcel'),('美森加班海派','Matson extra sea parcel'),
|
||||||
|
('美森正班海卡','Matson regular sea truck'),('美森加班海卡','Matson extra sea truck'),
|
||||||
|
('纽约EXX海铁','New York EXX sea rail'),('洛杉矶普船全卡','Los Angeles regular sea truck direct'),
|
||||||
|
('洛杉矶普船海卡','Los Angeles regular sea truck'),('芝加哥普船海铁','Chicago regular sea rail'),
|
||||||
|
('休斯顿普船海卡','Houston regular sea truck'),('纽约普船海卡','New York regular sea truck'),
|
||||||
|
('萨凡纳普船海卡','Savannah regular sea truck'),('EXX海卡','EXX sea truck'),('合德海卡','Hede sea truck'),
|
||||||
|
('合德海派','Hede sea parcel'),('普船海派','Regular sea parcel'),
|
||||||
|
('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。','Please provide the carton count, weight and dimensions of each carton to calculate chargeable weight and size surcharges.'),
|
||||||
|
('发我目的仓码或邮编,可继续匹配到仓线路。','Send the destination warehouse code or ZIP code to match the route.'),
|
||||||
|
('货物多少公斤、几箱?','What are the total weight and carton count?'),
|
||||||
|
('当前资料没有美国空运费率,下面列出海运方案。','The current rate sheet has no US air rates. Sea options are listed below.'),
|
||||||
|
('以下按各目的仓分别比较;实际分票请提供每个仓的重量。','Each destination is compared separately; provide the weight assigned to each shipment.'),
|
||||||
|
('按相同区域、计费单位及税费方式比较的较低价方案:','Lower-priced options within the same region, billing unit and tax basis:'),
|
||||||
|
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
|
||||||
|
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
|
||||||
|
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
|
||||||
|
('当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。','No matching route rate was found. Please provide the destination warehouse or ZIP code.'),
|
||||||
|
('美国线路参考时效','US reference transit times'),('美国线路报价','US route rates'),('基础运费','base freight'),('美国','US'),('义乌','Yiwu'),('华南','South China'),
|
||||||
|
('不包税','tax excluded'),('包税','tax included'),('美西','US West'),('美中','US Central'),('美东','US East'),
|
||||||
|
('开船后参考','Reference timing after sailing: '),('船开后','After sailing: '),('开船后','After sailing: '),
|
||||||
|
('若干','several '),
|
||||||
|
('提取',' until pickup'),('天左右',' days approximately'),('天',' days'),('元/KG',' CNY/kg'),('元/CBM',' CNY/CBM'),
|
||||||
|
('元',' CNY'),(':',': '),(';','; '),(',',', '),('(',' ('),(')',')'))
|
||||||
|
for source,target in replacements:text=text.replace(source,target)
|
||||||
|
text=re.sub(r'([东中西])(\d)区',lambda m:{'东':'East','中':'Central','西':'West'}[m[1]]+' zone '+m[2],text)
|
||||||
|
return text
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Resolve same-conversation quote edits and return local facts without model I/O."""
|
||||||
|
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
from .quote_data import load_book, postcode_fee
|
||||||
|
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
|
||||||
|
|
||||||
|
|
||||||
|
def intent_with_context(message, conv_id, book):
|
||||||
|
from copy import deepcopy
|
||||||
|
codes = {row['code'] for row in book['warehouses']}
|
||||||
|
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
|
||||||
|
intent = parse_intent(message,codes)
|
||||||
|
def destination_reset(value):
|
||||||
|
if value.get('warehouse') or value.get('unknown_warehouses'):return {'postal','zone','zones'}
|
||||||
|
if value.get('postal'):return {'warehouse','warehouses','unknown_warehouses','zone','zones'}
|
||||||
|
if value.get('zone'):return {'warehouse','warehouses','unknown_warehouses','postal'}
|
||||||
|
return set()
|
||||||
|
excluded=destination_reset(intent)
|
||||||
|
selected_destination=bool(excluded)
|
||||||
|
if selected_destination and not intent.get('unknown_warehouses'):excluded.add('unknown_warehouses')
|
||||||
|
if intent['transport'] or intent['kinds'] or intent['services']:excluded.update(('air_only','air_requested'))
|
||||||
|
if intent['air_only'] or intent['tax']=='excluded':excluded.add('kinds')
|
||||||
|
if intent.get('weight') is not None and not intent.get('packages'):excluded.add('packages')
|
||||||
|
explicit_dims=bool(intent['dims'])
|
||||||
|
if conv_id and is_followup(message,intent):
|
||||||
|
connection=connect()
|
||||||
|
try:
|
||||||
|
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
|
||||||
|
finally:connection.close()
|
||||||
|
for (previous,) in recent:
|
||||||
|
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
|
||||||
|
earlier=parse_intent(previous,codes)
|
||||||
|
if earlier['country']=='other':break
|
||||||
|
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
|
||||||
|
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
|
||||||
|
if not selected_destination and destination_reset(earlier):
|
||||||
|
excluded.update(destination_reset(earlier));selected_destination=True
|
||||||
|
if intent['dims'] is None and earlier['dims']:
|
||||||
|
intent['dims']=earlier['dims'];intent['dim_unit']=earlier['dim_unit']
|
||||||
|
for key,value in earlier.items():
|
||||||
|
if key in ('mode','text','errors','dim_unit','dims'):continue
|
||||||
|
if intent.get(key) is None and key not in excluded:intent[key]=deepcopy(value)
|
||||||
|
if not is_followup(previous,earlier):break
|
||||||
|
if explicit_dims and intent.get('packages'):
|
||||||
|
for piece in intent['packages']:
|
||||||
|
piece['dims']=intent['dims'];piece['dim_unit']=intent['dim_unit']
|
||||||
|
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:intent['country']='US'
|
||||||
|
return intent
|
||||||
|
|
||||||
|
|
||||||
|
def answer_quote(message, conv_id):
|
||||||
|
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
|
||||||
|
return None
|
||||||
|
# Avoid a new read for ordinary chat; short followups need their local context.
|
||||||
|
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
|
||||||
|
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
book = load_book()
|
||||||
|
if not book:
|
||||||
|
return None
|
||||||
|
intent = intent_with_context(message, conv_id, book)
|
||||||
|
if intent['country'] != 'US':
|
||||||
|
return None
|
||||||
|
if intent.get('errors'):
|
||||||
|
return {'t':'\n'.join(intent['errors'])}
|
||||||
|
if intent.get('unknown_warehouses'):
|
||||||
|
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
|
||||||
|
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
|
||||||
|
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
|
||||||
|
return None
|
||||||
|
for key in ('weight','cbm'):
|
||||||
|
if intent[key] is not None and not (Decimal(0) < Decimal(intent[key]) <= Decimal(1000000)):
|
||||||
|
return {'t':'请提供大于 0 的有效重量或体积。'}
|
||||||
|
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
|
||||||
|
return {'t':'请提供有效的货物箱数。'}
|
||||||
|
dimensions=[intent['dims']] if intent['dims'] else []
|
||||||
|
dimensions += [piece['dims'] for piece in intent.get('packages') or [] if piece.get('dims')]
|
||||||
|
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
|
||||||
|
return {'t':'请提供大于 0 的有效长宽高。'}
|
||||||
|
from .quote_answers import answer_business
|
||||||
|
business=answer_business(intent,book)
|
||||||
|
if business is not None:return {'t':business}
|
||||||
|
if intent['air_only']:
|
||||||
|
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
|
||||||
|
destinations = variants(intent,book)
|
||||||
|
fees = []
|
||||||
|
for destination in destinations:
|
||||||
|
postal = destination['postal']
|
||||||
|
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
|
||||||
|
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
|
||||||
|
fees.append(postcode_fee(book['id'],postal) if postal else None)
|
||||||
|
return {'t':render_rates(intent,book,destinations,fees)}
|
||||||
|
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
|
||||||
|
return {'t':'报价数据暂时无法读取,请稍后重试。'}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Extract only this workbook's local detail cells; never evaluate external links."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from .quote_data import decimal_text
|
||||||
|
|
||||||
|
|
||||||
|
NS = {'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
|
||||||
|
TRUCK_SHEETS = ('美森正班海卡','美森加班海卡','EXX海卡','合德海卡','纽约EXX海铁',
|
||||||
|
'洛杉矶普船海卡','洛杉矶普船全卡','芝加哥普船海铁','休斯顿普船海卡','纽约普船海卡','萨凡纳普船海卡')
|
||||||
|
|
||||||
|
|
||||||
|
def read_sheets(path):
|
||||||
|
path = Path(path)
|
||||||
|
if path.stat().st_size > 20*1024*1024:
|
||||||
|
raise ValueError('Quote workbook is too large')
|
||||||
|
with ZipFile(path) as archive:
|
||||||
|
if sum(info.file_size for info in archive.infolist()) > 100*1024*1024:
|
||||||
|
raise ValueError('Expanded quote workbook is too large')
|
||||||
|
strings = []
|
||||||
|
if 'xl/sharedStrings.xml' in archive.namelist():
|
||||||
|
for entry in ET.fromstring(archive.read('xl/sharedStrings.xml')):
|
||||||
|
strings.append(''.join(t.text or '' for t in entry.findall('.//s:t', NS)))
|
||||||
|
workbook = ET.fromstring(archive.read('xl/workbook.xml'))
|
||||||
|
rels = {r.attrib['Id']:r.attrib['Target'] for r in ET.fromstring(archive.read('xl/_rels/workbook.xml.rels'))}
|
||||||
|
result = {}
|
||||||
|
for sheet in workbook.find('s:sheets', NS):
|
||||||
|
target = rels[sheet.attrib['{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id']]
|
||||||
|
target = target.lstrip('/') if target.startswith('/') else posixpath.normpath('xl/'+target)
|
||||||
|
rows = {}
|
||||||
|
for cell in ET.fromstring(archive.read(target)).findall('s:sheetData/s:row/s:c', NS):
|
||||||
|
ref = cell.attrib['r']
|
||||||
|
column = re.sub('[0-9]', '', ref)
|
||||||
|
if len(column) != 1 or column > 'Q':
|
||||||
|
continue
|
||||||
|
raw = cell.find('s:v', NS)
|
||||||
|
value = raw.text if raw is not None else ''
|
||||||
|
if cell.attrib.get('t') == 's':
|
||||||
|
value = strings[int(value)]
|
||||||
|
elif cell.attrib.get('t') == 'inlineStr':
|
||||||
|
value = ''.join(t.text or '' for t in cell.findall('.//s:t', NS))
|
||||||
|
formula = cell.find('s:f', NS)
|
||||||
|
if formula is not None and formula.text and re.search(r'\[[0-9]+\]', formula.text):
|
||||||
|
continue
|
||||||
|
if value:
|
||||||
|
rows.setdefault(int(re.sub('[^0-9]', '', ref)), {})[column] = value
|
||||||
|
result[sheet.attrib['name']] = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def export_snapshot(path):
|
||||||
|
sheets = read_sheets(path)
|
||||||
|
missing = set(TRUCK_SHEETS + ('海派','FBA分区表','亚马逊仓库代码表','UPS&FedEx偏远地区表')) - sheets.keys()
|
||||||
|
if missing:
|
||||||
|
raise ValueError('Quote workbook is missing required local detail sheets')
|
||||||
|
result = {'schema':1, 'source_name':Path(path).name,
|
||||||
|
'source_sha256':hashlib.sha256(Path(path).read_bytes()).hexdigest(),
|
||||||
|
'rates':[], 'warehouses':[], 'postcodes':[], 'documents':[]}
|
||||||
|
for name in TRUCK_SHEETS:
|
||||||
|
rows = sheets[name]
|
||||||
|
destination_col = 'A' if name in TRUCK_SHEETS[:4] else 'B'
|
||||||
|
price_cols = ('B','C','D') if destination_col == 'A' else ('C','D','E')
|
||||||
|
eta_col = 'E' if destination_col == 'A' else 'F'
|
||||||
|
for number, row in rows.items():
|
||||||
|
if number < 6 or not re.fullmatch(r'\d+(?:\.\d+)?', row.get(price_cols[0], '')):
|
||||||
|
continue
|
||||||
|
destinations = [p.strip() for p in re.split(r'[/\n、]+', row.get(destination_col, '')) if p.strip()]
|
||||||
|
if not destinations:
|
||||||
|
raise ValueError('Price row is missing destinations')
|
||||||
|
for column, origin, unit, tax in zip(price_cols, ('义乌','义乌','华南'), ('cbm','kg','kg'), ('excluded','included','included')):
|
||||||
|
result['rates'].append({'service':name, 'kind':'truck', 'origin':origin, 'zone':'',
|
||||||
|
'unit':unit, 'tax':tax, 'min_weight':'0', 'price':decimal_text(row[column], positive=True),
|
||||||
|
'eta':row.get(eta_col, ''), 'destinations':destinations, 'source_sheet':name, 'source_cell':f'{column}{number}'})
|
||||||
|
parcel = sheets['海派']
|
||||||
|
for origin, header, first in (('义乌',4,9), ('华南',13,18)):
|
||||||
|
for start_col in ('C','F','I','L'):
|
||||||
|
for offset, tier in enumerate(('12','51','101')):
|
||||||
|
column = chr(ord(start_col)+offset)
|
||||||
|
for index, zone in enumerate(('美西','美中','美东')):
|
||||||
|
number = first+index
|
||||||
|
result['rates'].append({'service':parcel[header][start_col], 'kind':'parcel', 'origin':origin,
|
||||||
|
'zone':zone, 'unit':'kg', 'tax':'included', 'min_weight':tier,
|
||||||
|
'price':decimal_text(parcel[number][column], positive=True),
|
||||||
|
'eta':parcel[header+2][start_col], 'destinations':[], 'source_sheet':'海派', 'source_cell':f'{column}{number}'})
|
||||||
|
for name, first in (('FBA分区表',3), ('亚马逊仓库代码表',2)):
|
||||||
|
for number, row in sheets[name].items():
|
||||||
|
if number < first or not row.get('A'):
|
||||||
|
continue
|
||||||
|
is_zone = name == 'FBA分区表'
|
||||||
|
result['warehouses'].append({'code':row['A'].strip().upper(),
|
||||||
|
'zone':row.get('B','') if is_zone else '', 'state':row.get('C','') if is_zone else '',
|
||||||
|
'postal':row.get('D','') if is_zone else '', 'address':row.get('B','') if not is_zone else '',
|
||||||
|
'source_sheet':name, 'source_cell':f'A{number}'})
|
||||||
|
for number, row in sheets['UPS&FedEx偏远地区表'].items():
|
||||||
|
if number <= 23:
|
||||||
|
continue
|
||||||
|
for column, severity, fee in (('B','remote','1'), ('F','extended','2'), ('J','extreme',None)):
|
||||||
|
postal = row.get(column, '')
|
||||||
|
if postal:
|
||||||
|
result['postcodes'].append({'postal':postal, 'severity':severity, 'fee':fee})
|
||||||
|
for name, rows in sheets.items():
|
||||||
|
if name in ('快速查询','UPS&FedEx偏远地区表','FBA分区表','亚马逊仓库代码表'):
|
||||||
|
continue
|
||||||
|
for number, row in rows.items():
|
||||||
|
if number <= 1 or (name in TRUCK_SHEETS and number <= 5):
|
||||||
|
continue
|
||||||
|
# Long detail text contains rules; short lookup rows contain service fees.
|
||||||
|
for column, value in row.items():
|
||||||
|
if len(value) >= 20 and not re.search(r'公司地址|TEL:|船名航次|预计开船日期|关税查询网址', value):
|
||||||
|
result['documents'].append({'sheet':name, 'cell':f'{column}{number}', 'text':value})
|
||||||
|
if name in ('一件代发收费','FDA申报清单'):
|
||||||
|
text = ';'.join(row.values())
|
||||||
|
result['documents'].append({'sheet':name,'cell':f'A{number}','text':text})
|
||||||
|
return result
|
||||||
@@ -2,6 +2,6 @@
|
|||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
def register_routes(app):
|
||||||
from . import admin, business, chat, files, history, human, pages
|
from . import admin, business, chat, customer, files, history, human, pages, speech, visitor
|
||||||
for module in (admin, business, chat, files, history, human, pages):
|
for module in (admin, business, chat, customer, files, history, human, pages, speech, visitor):
|
||||||
app.register_blueprint(module.bp)
|
app.register_blueprint(module.bp)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
||||||
from .. import business_service
|
from .. import business_service, visitor_service
|
||||||
|
|
||||||
bp = Blueprint('business', __name__)
|
bp = Blueprint('business', __name__)
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ def my_orders():
|
|||||||
|
|
||||||
@bp.route('/api/ticket', methods=['POST'])
|
@bp.route('/api/ticket', methods=['POST'])
|
||||||
def add_ticket():
|
def add_ticket():
|
||||||
return business_service.add_ticket(request.get_json() or {})
|
return business_service.add_ticket(request.get_json(silent=True))
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/api/tickets')
|
@bp.route('/api/tickets')
|
||||||
@@ -23,4 +23,9 @@ def list_tickets():
|
|||||||
|
|
||||||
@bp.route('/api/rate', methods=['POST'])
|
@bp.route('/api/rate', methods=['POST'])
|
||||||
def rate_chat():
|
def rate_chat():
|
||||||
return business_service.rate_chat(request.json or {})
|
data = request.get_json(silent=True) or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok': False}, 400
|
||||||
|
if not visitor_service.owns_human_session(data.get('sid')):
|
||||||
|
return {'ok': False}, 404
|
||||||
|
return business_service.rate_chat(data)
|
||||||
|
|||||||
@@ -8,17 +8,36 @@ bp = Blueprint('chat', __name__)
|
|||||||
|
|
||||||
@bp.route('/stream_chat', methods=['GET', 'POST'])
|
@bp.route('/stream_chat', methods=['GET', 'POST'])
|
||||||
def stream_chat():
|
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':
|
if request.method == 'POST':
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True)
|
||||||
msg = (data.get('msg') or '').strip()
|
if not isinstance(data, dict):
|
||||||
|
return error('请求格式无效,请重新发送问题。')
|
||||||
|
msg = data.get('msg', '')
|
||||||
conv_id = data.get('conv_id')
|
conv_id = data.get('conv_id')
|
||||||
try:
|
|
||||||
conv_id = int(conv_id) if conv_id else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
conv_id = None
|
|
||||||
else:
|
else:
|
||||||
msg = (request.args.get('msg') or '').strip()
|
msg = request.args.get('msg', '')
|
||||||
conv_id = request.args.get('conv_id', type=int)
|
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)
|
frames, headers = chat_service.create_chat_stream(msg, conv_id)
|
||||||
body = frames if isinstance(frames, str) else stream_with_context(frames)
|
body = frames if isinstance(frames, str) else stream_with_context(frames)
|
||||||
return Response(body, mimetype='text/event-stream', headers=headers)
|
return Response(body, mimetype='text/event-stream', headers=headers)
|
||||||
@@ -26,4 +45,4 @@ def stream_chat():
|
|||||||
|
|
||||||
@bp.route('/api/feedback', methods=['POST'])
|
@bp.route('/api/feedback', methods=['POST'])
|
||||||
def feedback():
|
def feedback():
|
||||||
return business_service.feedback(request.get_json(silent=True) or {})
|
return business_service.feedback(request.get_json(silent=True))
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Public, visitor-bound customer login endpoints. No staff privileges."""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
from .. import customer_service
|
||||||
|
|
||||||
|
bp = Blueprint('customer', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/api/customer/session')
|
||||||
|
def session():
|
||||||
|
return customer_service.session_status()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/api/customer/login')
|
||||||
|
def login():
|
||||||
|
request.max_content_length = 2048
|
||||||
|
try:
|
||||||
|
return customer_service.login(request.get_json(silent=True), current_app.extensions['customer_login_attempts'],
|
||||||
|
current_app.extensions['customer_login_lock'])
|
||||||
|
except RequestEntityTooLarge:
|
||||||
|
return {'ok':False,'error':'登录信息过长。'}, 413
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/api/customer/logout')
|
||||||
|
def logout():
|
||||||
|
return customer_service.logout()
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
"""HTTP adapters for files; business logic lives in services."""
|
"""HTTP adapters for files; business logic lives in services."""
|
||||||
|
|
||||||
from flask import Blueprint, request, send_from_directory
|
from flask import Blueprint, render_template, request, send_from_directory
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from .. import file_service
|
from .. import file_service, visitor_service
|
||||||
from ..config import get_setting
|
from ..config import get_setting
|
||||||
|
|
||||||
bp = Blueprint('files', __name__)
|
bp = Blueprint('files', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def unavailable(staff=False):
|
||||||
|
return render_template('attachment_unavailable.html', staff=staff), 404, {'Cache-Control':'private, no-store'}
|
||||||
|
|
||||||
@bp.route('/api/upload', methods=['POST'])
|
@bp.route('/api/upload', methods=['POST'])
|
||||||
def upload_file():
|
def upload_file():
|
||||||
return file_service.upload_file(request.files)
|
return file_service.upload_file(request.files)
|
||||||
@@ -14,4 +19,21 @@ def upload_file():
|
|||||||
|
|
||||||
@bp.route('/uploads/<path:name>')
|
@bp.route('/uploads/<path:name>')
|
||||||
def uploaded_file(name):
|
def uploaded_file(name):
|
||||||
return send_from_directory(get_setting('UPLOAD_DIR'), name)
|
if not visitor_service.owns_upload(name):
|
||||||
|
return unavailable()
|
||||||
|
try:
|
||||||
|
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||||
|
except NotFound:
|
||||||
|
return unavailable()
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/staff/uploads/<path:name>')
|
||||||
|
def staff_uploaded_file(name):
|
||||||
|
try:
|
||||||
|
response = send_from_directory(get_setting('UPLOAD_DIR'), name)
|
||||||
|
except NotFound:
|
||||||
|
return unavailable(staff=True)
|
||||||
|
response.headers['Cache-Control'] = 'private, no-store'
|
||||||
|
return response
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""HTTP adapters for short voice input; no audio is stored or added to history."""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from flask import Blueprint, Request, request
|
||||||
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
|
from .. import speech_service
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint('speech', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryAudioRequest(Request):
|
||||||
|
def _get_file_stream(self, total_content_length, content_type, filename=None, content_length=None):
|
||||||
|
if self.path == '/api/speech/transcribe':
|
||||||
|
return BytesIO()
|
||||||
|
return super()._get_file_stream(total_content_length, content_type, filename, content_length)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.after_request
|
||||||
|
def no_cache(response):
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get('/api/speech/config')
|
||||||
|
def speech_config():
|
||||||
|
return {'enabled': speech_service.speech_enabled(), 'max_seconds': speech_service.MAX_DURATION_SECONDS,
|
||||||
|
'max_bytes': speech_service.MAX_AUDIO_BYTES}, {'Cache-Control': 'no-store'}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/api/speech/transcribe')
|
||||||
|
def speech_transcribe():
|
||||||
|
request.max_content_length = speech_service.MAX_AUDIO_BYTES + 64 * 1024
|
||||||
|
if not speech_service.speech_enabled():
|
||||||
|
return {'ok': False, 'error': speech_service.UNAVAILABLE}, 503
|
||||||
|
try:
|
||||||
|
files = request.files
|
||||||
|
try:
|
||||||
|
return speech_service.transcribe_recording(files.get('audio'), request.form.get('duration'),
|
||||||
|
request.form.get('language'))
|
||||||
|
finally:
|
||||||
|
for _, upload in files.items(multi=True):
|
||||||
|
upload.close()
|
||||||
|
except RequestEntityTooLarge:
|
||||||
|
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Guest bootstrap and owner-checked human support adapters, separate from staff APIs."""
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, request
|
||||||
|
from werkzeug.exceptions import RequestEntityTooLarge
|
||||||
|
|
||||||
|
from .. import human_service, visitor_service
|
||||||
|
|
||||||
|
|
||||||
|
bp = Blueprint('visitor', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post('/api/visitor/session')
|
||||||
|
def visitor_session():
|
||||||
|
request.max_content_length = 1024
|
||||||
|
try:
|
||||||
|
return visitor_service.start_session(request.get_json(silent=True))
|
||||||
|
except RequestEntityTooLarge:
|
||||||
|
return {'ok': False, 'error': '访客会话格式无效。'}, 413
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/api/visitor/human/<action>', methods=['GET', 'POST'])
|
||||||
|
def visitor_human(action):
|
||||||
|
methods = {'poll': 'GET', 'request': 'POST', 'send': 'POST', 'close': 'POST'}
|
||||||
|
if action not in methods or request.method != methods[action]:
|
||||||
|
return {'ok': False}, 404
|
||||||
|
data = request.get_json(silent=True) if request.method == 'POST' else {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok': False}, 400
|
||||||
|
if action == 'request':
|
||||||
|
if not visitor_service.owns_conversation(data.get('conv_id')):
|
||||||
|
return {'ok': False, 'error': 'conversation_not_found'}, 404
|
||||||
|
return human_service.human_request(data)
|
||||||
|
sid = request.args.get('sid', type=int) if action == 'poll' else data.get('sid')
|
||||||
|
if not visitor_service.owns_human_session(sid):
|
||||||
|
return {'ok': False, 'error': 'session_not_found'}, 404
|
||||||
|
if action == 'poll':
|
||||||
|
after = request.args.get('after', 0, type=int)
|
||||||
|
if not 0 <= after <= 9223372036854775807:
|
||||||
|
return {'ok': False}, 400
|
||||||
|
result = human_service.human_poll(sid, after, current_app.extensions['human_typing'])
|
||||||
|
result.pop('note', None)
|
||||||
|
return result
|
||||||
|
if action == 'send':
|
||||||
|
if data.get('side', 'customer') != 'customer':
|
||||||
|
return {'ok': False}, 403
|
||||||
|
return human_service.human_send({**data, 'side': 'customer'})
|
||||||
|
return human_service.human_close(data)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Bounded, in-memory recording transcription; separate from text chat credentials."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
MAX_AUDIO_BYTES = 5 * 1024 * 1024
|
||||||
|
MAX_DURATION_SECONDS = 60
|
||||||
|
UNAVAILABLE = '语音输入暂不可用,请使用文字输入。'
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechFailure(Exception):
|
||||||
|
def __init__(self, message, status=502):
|
||||||
|
self.message, self.status = message, status
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_binary():
|
||||||
|
configured = get_setting('FFMPEG_BIN')
|
||||||
|
return str(configured) if configured and Path(configured).is_file() else shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def speech_enabled():
|
||||||
|
provider = get_setting('ASR_PROVIDER')
|
||||||
|
if provider == 'baidu':
|
||||||
|
return bool(get_setting('BAIDU_ASR_API_KEY') and get_setting('BAIDU_ASR_SECRET_KEY') and ffmpeg_binary())
|
||||||
|
if provider != 'dashscope':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
url = urlsplit(get_setting('ASR_URL'))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return bool(get_setting('ASR_API_KEY') and get_setting('ASR_MODEL')
|
||||||
|
and url.scheme == 'https' and url.hostname and not url.username and not url.password)
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(url, **options):
|
||||||
|
response = requests.post(url, timeout=(5, 30), stream=True, allow_redirects=False, **options)
|
||||||
|
try:
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise SpeechFailure('语音转文字失败,请稍后重试。')
|
||||||
|
data = bytearray()
|
||||||
|
for chunk in response.iter_content(4096):
|
||||||
|
data.extend(chunk)
|
||||||
|
if len(data) > 64 * 1024:
|
||||||
|
raise SpeechFailure('语音识别结果无效,请重试。')
|
||||||
|
return json.loads(data)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
|
||||||
|
def baidu_token():
|
||||||
|
key, secret = get_setting('BAIDU_ASR_API_KEY'), get_setting('BAIDU_ASR_SECRET_KEY')
|
||||||
|
identity = hashlib.sha256((key + '\0' + secret).encode()).hexdigest()
|
||||||
|
lock = current_app.extensions.setdefault('speech_token_lock', threading.Lock())
|
||||||
|
with lock:
|
||||||
|
cached = current_app.extensions.get('speech_token')
|
||||||
|
if cached and cached['identity'] == identity and cached['expires'] > time.time():
|
||||||
|
return cached['token']
|
||||||
|
data = post_json('https://aip.baidubce.com/oauth/2.0/token', data={
|
||||||
|
'grant_type':'client_credentials', 'client_id':key, 'client_secret':secret})
|
||||||
|
token = data.get('access_token')
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
raise SpeechFailure(UNAVAILABLE, 503)
|
||||||
|
current_app.extensions['speech_token'] = {'identity':identity, 'token':token,
|
||||||
|
'expires':time.time() + max(0, min(float(data.get('expires_in', 0)), 2592000) - 60)}
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def to_pcm(content):
|
||||||
|
"""Decode browser WebM/MP4 in pipes to mono 16 kHz, without temporary audio files."""
|
||||||
|
binary = ffmpeg_binary()
|
||||||
|
if not binary:
|
||||||
|
raise SpeechFailure(UNAVAILABLE, 503)
|
||||||
|
result = subprocess.run([binary, '-hide_banner', '-loglevel', 'error', '-protocol_whitelist', 'pipe',
|
||||||
|
'-i', 'pipe:0', '-map', '0:a:0', '-vn', '-sn', '-dn', '-ac', '1', '-ar', '16000',
|
||||||
|
'-t', '61', '-f', 's16le', 'pipe:1'], input=content, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
timeout=10, creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
||||||
|
if result.returncode != 0 or len(result.stdout) < 12800:
|
||||||
|
raise SpeechFailure('录音无法识别,请重新录制。', 422)
|
||||||
|
if len(result.stdout) > 16000 * 2 * MAX_DURATION_SECONDS:
|
||||||
|
raise SpeechFailure('录音超过 60 秒,请缩短后重试。', 400)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def baidu_transcribe(content, language):
|
||||||
|
pcm = to_pcm(content)
|
||||||
|
result = post_json('https://vop.baidu.com/server_api', json={'format':'pcm', 'rate':16000, 'channel':1,
|
||||||
|
'cuid':'baiyue-logistics-chat', 'token':baidu_token(), 'dev_pid':1737 if language == 'en' else 1537,
|
||||||
|
'speech':base64.b64encode(pcm).decode('ascii'), 'len':len(pcm)})
|
||||||
|
code = result.get('err_no')
|
||||||
|
if code == 3301:
|
||||||
|
raise SpeechFailure('没有识别到文字,请靠近麦克风重试。', 422)
|
||||||
|
if code != 0:
|
||||||
|
current_app.logger.warning('Baidu ASR request failed: code=%s', code)
|
||||||
|
raise SpeechFailure(UNAVAILABLE if code == 3302 else '语音转文字失败,请稍后重试。', 503 if code == 3302 else 502)
|
||||||
|
parts = result.get('result')
|
||||||
|
if not isinstance(parts, list) or any(not isinstance(part, str) for part in parts):
|
||||||
|
raise SpeechFailure('语音识别结果无效,请重试。')
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def audio_format(content, mime):
|
||||||
|
"""Check container signatures, never trust the uploaded filename."""
|
||||||
|
mime = mime.split(';', 1)[0].lower()
|
||||||
|
if mime == 'audio/webm' and content.startswith(b'\x1aE\xdf\xa3'):
|
||||||
|
return 'audio/webm'
|
||||||
|
if mime in ('audio/mp4', 'audio/x-m4a') and content[4:8] == b'ftyp':
|
||||||
|
return 'audio/mp4'
|
||||||
|
if mime in ('audio/ogg', 'audio/opus') and content.startswith(b'OggS'):
|
||||||
|
return 'audio/ogg'
|
||||||
|
if mime in ('audio/wav', 'audio/x-wav') and content[:4] == b'RIFF' and content[8:12] == b'WAVE':
|
||||||
|
return 'audio/wav'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_recording(upload, duration, language):
|
||||||
|
if not speech_enabled():
|
||||||
|
return {'ok': False, 'error': UNAVAILABLE}, 503
|
||||||
|
if not upload:
|
||||||
|
return {'ok': False, 'error': '没有收到录音,请重新按住说话。'}, 400
|
||||||
|
try:
|
||||||
|
duration = float(duration)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
duration = 0
|
||||||
|
if not math.isfinite(duration) or not 0.4 <= duration <= MAX_DURATION_SECONDS + 1:
|
||||||
|
return {'ok': False, 'error': '录音时长无效,请录制 1 至 60 秒语音。'}, 400
|
||||||
|
content = upload.read(MAX_AUDIO_BYTES + 1)
|
||||||
|
if len(content) > MAX_AUDIO_BYTES:
|
||||||
|
return {'ok': False, 'error': '录音文件过大,请缩短后重试。'}, 413
|
||||||
|
mime = audio_format(content, upload.mimetype)
|
||||||
|
if len(content) < 16 or not mime:
|
||||||
|
return {'ok': False, 'error': '录音格式不受支持,请使用文字输入或更换浏览器。'}, 415
|
||||||
|
language = 'en' if language == 'en' else 'zh'
|
||||||
|
try:
|
||||||
|
if get_setting('ASR_PROVIDER') == 'baidu':
|
||||||
|
text = baidu_transcribe(content, language)
|
||||||
|
else:
|
||||||
|
body = {'model': get_setting('ASR_MODEL'), 'stream': False,
|
||||||
|
'messages': [{'role': 'user', 'content': [{'type': 'input_audio', 'input_audio': {
|
||||||
|
'data': 'data:' + mime + ';base64,' + base64.b64encode(content).decode('ascii')}}]}],
|
||||||
|
'asr_options': {'language': language, 'enable_itn': True}}
|
||||||
|
result = post_json(get_setting('ASR_URL'), json=body,
|
||||||
|
headers={'Authorization': 'Bearer ' + get_setting('ASR_API_KEY')})
|
||||||
|
text = result['choices'][0]['message']['content']
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return {'ok': False, 'error': '没有识别到文字,请靠近麦克风重试。'}, 422
|
||||||
|
text = text.strip()
|
||||||
|
if len(text) > 2000:
|
||||||
|
return {'ok': False, 'error': '识别文字过长,请缩短录音后重试。'}, 422
|
||||||
|
return {'ok': True, 'text': text}, 200
|
||||||
|
except requests.Timeout:
|
||||||
|
return {'ok': False, 'error': '语音转文字超时,请重试。'}, 504
|
||||||
|
except SpeechFailure as error:
|
||||||
|
return {'ok': False, 'error': error.message}, error.status
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {'ok': False, 'error': '录音处理超时,请缩短后重试。'}, 504
|
||||||
|
except OSError:
|
||||||
|
return {'ok': False, 'error': UNAVAILABLE}, 503
|
||||||
|
except (requests.RequestException, ValueError, KeyError, IndexError, TypeError):
|
||||||
|
return {'ok': False, 'error': '语音转文字失败,请稍后重试。'}, 502
|
||||||
@@ -13,7 +13,9 @@ def card_to_text(content):
|
|||||||
return content
|
return content
|
||||||
try:
|
try:
|
||||||
card = json.loads(content[len(CARD_PREFIX):])
|
card = json.loads(content[len(CARD_PREFIX):])
|
||||||
return f"(已为运单 {card.get('no', '')} 查询过轨迹,当前状态:{card.get('status', '')})"
|
if card.get('kind') == 'order':
|
||||||
|
return card.get('summary', '')
|
||||||
|
return f"(历史演示轨迹,非真实物流信息:单号 {card.get('no', '')},演示状态:{card.get('status', '')})"
|
||||||
except Exception:
|
except Exception:
|
||||||
return "(已为该运单查询过轨迹)"
|
return "(已为该运单查询过轨迹)"
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ def extract_tracking_no(text):
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
def build_track(no):
|
def build_track(no):
|
||||||
"""生成固定的模拟轨迹:同一个单号每次查都一样"""
|
"""Legacy demo helper for old imports; chat queries use order_test_data."""
|
||||||
rnd = random.Random(no)
|
rnd = random.Random(no)
|
||||||
steps = [
|
steps = [
|
||||||
("已揽收", "义乌仓"),
|
("已揽收", "义乌仓"),
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Guest possession tokens isolate chat data; they never identify an order customer."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from flask import g, has_app_context, jsonify, request
|
||||||
|
|
||||||
|
from .db import connect
|
||||||
|
|
||||||
|
|
||||||
|
COOKIE_NAME = 'baiyue_visitor'
|
||||||
|
KEY_PATTERN = re.compile(r'[0-9a-f]{64}\Z')
|
||||||
|
CUSTOMER_PATHS = {
|
||||||
|
'/stream_chat', '/api/new_chat', '/api/get_history', '/api/get_messages', '/api/del_history',
|
||||||
|
'/api/clear_all', '/api/upload', '/api/ticket', '/api/rate', '/api/feedback',
|
||||||
|
'/api/speech/config', '/api/speech/transcribe',
|
||||||
|
'/api/customer/session', '/api/customer/login', '/api/customer/logout',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_key(value):
|
||||||
|
return isinstance(value, str) and KEY_PATTERN.fullmatch(value) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def visitor_id():
|
||||||
|
return getattr(g, 'visitor_id', None) if has_app_context() else None
|
||||||
|
|
||||||
|
|
||||||
|
def customer_tenant_id():
|
||||||
|
# Set only after validating a server-side customer session.
|
||||||
|
# Visitor cookies, request parameters and headers are deliberately not accepted.
|
||||||
|
value = getattr(g, 'customer_tenant_id', None) if has_app_context() else None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||||
|
return None
|
||||||
|
tenant = str(value).strip()
|
||||||
|
return tenant if tenant and len(tenant) <= 128 else None
|
||||||
|
|
||||||
|
|
||||||
|
def customer_tenant_ids():
|
||||||
|
if not has_app_context():
|
||||||
|
return ()
|
||||||
|
# Also retain compatibility with a trusted single-tenant authentication adapter.
|
||||||
|
single = customer_tenant_id()
|
||||||
|
if single:
|
||||||
|
return (single,)
|
||||||
|
values = getattr(g,'customer_tenant_ids',())
|
||||||
|
if not isinstance(values,(list,tuple)) or len(values) > 256:
|
||||||
|
return ()
|
||||||
|
return tuple(sorted(set(value for value in values if isinstance(value,str) and 0 < len(value.strip()) <= 128)))
|
||||||
|
|
||||||
|
|
||||||
|
def attach_visitor():
|
||||||
|
key = request.cookies.get(COOKIE_NAME)
|
||||||
|
g.visitor_id = hashlib.sha256(key.encode('ascii')).hexdigest() if valid_key(key) else None
|
||||||
|
is_customer = request.path in CUSTOMER_PATHS or request.path.startswith('/api/visitor/human/')
|
||||||
|
if is_customer and not visitor_id():
|
||||||
|
return jsonify({'ok': False, 'e': '访客会话已失效,请刷新页面重试。'}), 401
|
||||||
|
if (is_customer or request.path == '/api/visitor/session') and request.method not in ('GET', 'HEAD', 'OPTIONS'):
|
||||||
|
origin = request.headers.get('Origin')
|
||||||
|
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):
|
||||||
|
return jsonify({'ok': False, 'e': '请求来源无效。'}), 403
|
||||||
|
|
||||||
|
|
||||||
|
def start_session(data):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {'ok': False, 'error': '访客会话格式无效。'}, 400
|
||||||
|
key = data.get('key')
|
||||||
|
if key is None:
|
||||||
|
existing = request.cookies.get(COOKIE_NAME)
|
||||||
|
key = existing if valid_key(existing) else secrets.token_hex(32)
|
||||||
|
if not valid_key(key):
|
||||||
|
return {'ok': False, 'error': '访客标识无效。'}, 400
|
||||||
|
response = jsonify({'ok': True, 'key': key})
|
||||||
|
response.set_cookie(COOKIE_NAME, key, max_age=365*24*3600, httponly=True,
|
||||||
|
secure=request.is_secure, samesite='Lax', path='/')
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def owns_conversation(conv_id):
|
||||||
|
from .customer_service import current_customer_id, current_scope_key
|
||||||
|
owner = visitor_id()
|
||||||
|
if not owner or not isinstance(conv_id, int) or isinstance(conv_id, bool) or not 0 < conv_id <= 9223372036854775807:
|
||||||
|
return False
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
return bool(connection.execute('SELECT 1 FROM chat_history WHERE id=? AND visitor_id=? '
|
||||||
|
'AND (customer_id IS NULL OR (customer_id=? AND customer_scope=?))',
|
||||||
|
(conv_id, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def owns_human_session(sid):
|
||||||
|
from .customer_service import current_customer_id, current_scope_key
|
||||||
|
owner = visitor_id()
|
||||||
|
if not owner or not isinstance(sid, int) or isinstance(sid, bool) or not 0 < sid <= 9223372036854775807:
|
||||||
|
return False
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
return bool(connection.execute('SELECT 1 FROM human_sessions s JOIN chat_history h ON h.id=s.conv_id '
|
||||||
|
'WHERE s.id=? AND h.visitor_id=? AND (h.customer_id IS NULL OR '
|
||||||
|
'(h.customer_id=? AND h.customer_scope=?))',
|
||||||
|
(sid, owner, current_customer_id(),current_scope_key())).fetchone())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def owns_upload(name):
|
||||||
|
owner = visitor_id()
|
||||||
|
if not owner:
|
||||||
|
return False
|
||||||
|
connection = connect()
|
||||||
|
try:
|
||||||
|
return bool(connection.execute('SELECT 1 FROM visitor_uploads WHERE filename=? AND visitor_id=?',
|
||||||
|
(name, owner)).fetchone())
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Read order-linked warehouse and shipping facts into an explicit private snapshot.
|
||||||
|
|
||||||
|
Requires the optional PyMySQL package. This command never writes to the source DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def export_tracking(connection, orders):
|
||||||
|
output = {}
|
||||||
|
for order in orders:
|
||||||
|
key = (str(order.get('tenant_id') or ''), str(order.get('source_order_id') or ''))
|
||||||
|
if not all(value.isdecimal() for value in key) or not order.get('order_no'):
|
||||||
|
raise ValueError('Source snapshot requires numeric tenant_id/source_order_id and order_no')
|
||||||
|
if key in output:
|
||||||
|
raise ValueError('Source snapshot contains duplicate order identities')
|
||||||
|
output[key] = {key_name: order[key_name] for key_name in ('tenant_id', 'source_order_id', 'order_no')}
|
||||||
|
output[key]['tracking_json'] = []
|
||||||
|
ids = sorted({key[1] for key in output})
|
||||||
|
for start in range(0, len(ids), 100):
|
||||||
|
batch = ids[start:start + 100]
|
||||||
|
placeholders = ','.join('%s' for _ in batch)
|
||||||
|
# Every relation uses both source order identity and customer identity.
|
||||||
|
queries = [
|
||||||
|
(f'''SELECT o.tenant_id,o.id AS order_id, i.id AS fact_id,
|
||||||
|
i.inbounded_at AS actual_at,i.updated_at AS observed_at
|
||||||
|
FROM baiyue_order.ord_order o
|
||||||
|
JOIN baiyue_firstleg.fl_inbound_order i ON i.order_id=o.id AND i.tenant_id=o.tenant_id
|
||||||
|
WHERE o.deleted=0 AND i.deleted=0 AND i.inbounded_at IS NOT NULL
|
||||||
|
AND o.id IN ({placeholders}) ORDER BY i.id''', 'inbound'),
|
||||||
|
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,i.id AS fact_id,
|
||||||
|
i.frozen_at AS actual_at,i.frozen_at AS observed_at,
|
||||||
|
p.container_booking_id,b.container_no
|
||||||
|
FROM baiyue_order.ord_order o
|
||||||
|
JOIN baiyue_firstleg.fl_loading_plan_item i
|
||||||
|
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0
|
||||||
|
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||||
|
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
|
||||||
|
WHERE o.deleted=0 AND i.frozen_at IS NOT NULL
|
||||||
|
AND o.id IN ({placeholders}) ORDER BY i.id''', 'loading'),
|
||||||
|
(f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,n.id AS fact_id,n.standard_node,
|
||||||
|
n.actual_at,n.estimated_at,n.observed_at,n.created_at,n.location_name,
|
||||||
|
n.container_booking_id,b.container_no
|
||||||
|
FROM baiyue_order.ord_order o
|
||||||
|
JOIN baiyue_firstleg.fl_loading_plan_item i
|
||||||
|
ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0 AND i.frozen_at IS NOT NULL
|
||||||
|
JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
|
||||||
|
JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
|
||||||
|
JOIN baiyue_firstleg.fl_carrier_schedule_node_fact n ON n.container_booking_id=b.id
|
||||||
|
WHERE o.deleted=0 AND o.id IN ({placeholders}) ORDER BY n.id''', 'shipping'),
|
||||||
|
]
|
||||||
|
for sql, source in queries:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(sql, batch)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
key = (str(row['tenant_id']), str(row['order_id']))
|
||||||
|
if key not in output:
|
||||||
|
continue
|
||||||
|
fact = {'event_id': source + ':' + str(row['fact_id']),
|
||||||
|
'code': {'inbound': 'DOMESTIC_RECEIVED', 'loading': 'LOADING_COMPLETED'}.get(source)
|
||||||
|
or row['standard_node'],
|
||||||
|
'shipment_key': str(row.get('container_booking_id') or ''),
|
||||||
|
'shipment_no': row.get('container_no') or '',
|
||||||
|
'place': row.get('location_name') or ''}
|
||||||
|
for field in ('actual_at', 'estimated_at', 'observed_at', 'created_at'):
|
||||||
|
value = row.get(field)
|
||||||
|
fact[field] = value.isoformat(sep=' ') if value else None
|
||||||
|
output[key]['tracking_json'].append(fact)
|
||||||
|
return list(output.values())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--database-env', type=Path, required=True)
|
||||||
|
parser.add_argument('--orders-snapshot', type=Path, required=True)
|
||||||
|
parser.add_argument('--output', type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.output.exists(): parser.error('Output already exists; choose a new private snapshot path')
|
||||||
|
if not args.output.parent.is_dir(): parser.error('Output parent directory must exist')
|
||||||
|
if not args.orders_snapshot.is_file() or args.orders_snapshot.stat().st_size > 10 * 1024 * 1024:
|
||||||
|
parser.error('Orders snapshot must be an existing JSON file smaller than 10 MB')
|
||||||
|
orders = json.loads(args.orders_snapshot.read_text(encoding='utf-8-sig'))
|
||||||
|
if isinstance(orders, dict): orders = orders.get('orders')
|
||||||
|
if not isinstance(orders, list) or not orders or len(orders) > 3000:
|
||||||
|
parser.error('Orders snapshot must contain 1 to 3000 orders')
|
||||||
|
import pymysql
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
config = dotenv_values(args.database_env, interpolate=False, encoding='utf-8-sig')
|
||||||
|
required = ('CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD')
|
||||||
|
if any(not config.get(key) for key in required): parser.error('Database env is missing connection settings')
|
||||||
|
connection = pymysql.connect(host=config[required[0]], port=int(config[required[1]]),
|
||||||
|
user=config[required[2]], password=config[required[3]], cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
connect_timeout=10, read_timeout=30, write_timeout=10, autocommit=False)
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute('SET SESSION TRANSACTION READ ONLY')
|
||||||
|
cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
|
||||||
|
records = export_tracking(connection, orders)
|
||||||
|
finally:
|
||||||
|
connection.rollback()
|
||||||
|
connection.close()
|
||||||
|
args.output.write_text(json.dumps({'orders': records}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
print(json.dumps({'orders': len(records), 'orders_with_tracking': sum(bool(row['tracking_json']) for row in records),
|
||||||
|
'facts': sum(len(row['tracking_json']) for row in records)}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Import an explicit, private order snapshot into the chat SQLite database."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from logistics_chat.order_data import import_records, import_tracking_records
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--snapshot', type=Path, required=True)
|
||||||
|
parser.add_argument('--db', type=Path, required=True)
|
||||||
|
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
|
||||||
|
parser.add_argument('--tracking-only', action='store_true', help='Update only tracking for matching local orders')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
|
||||||
|
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
|
||||||
|
records = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
|
||||||
|
if isinstance(records, dict): records = records.get('orders')
|
||||||
|
if not isinstance(records, list) or len(records) > 3000:
|
||||||
|
parser.error('Snapshot must contain a list of at most 3000 orders')
|
||||||
|
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
|
||||||
|
with sqlite3.connect(args.db) as connection:
|
||||||
|
importer = import_tracking_records if args.tracking_only else import_records
|
||||||
|
result = importer(connection, records, replace=args.replace)
|
||||||
|
print(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Export local workbook details or import a private quote snapshot into SQLite."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import closing
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from logistics_chat.quote_data import import_snapshot
|
||||||
|
from logistics_chat.quote_workbook import export_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
source = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
source.add_argument('--xlsx', type=Path)
|
||||||
|
source.add_argument('--snapshot', type=Path)
|
||||||
|
parser.add_argument('--output', type=Path, help='New private snapshot output for --xlsx')
|
||||||
|
parser.add_argument('--db', type=Path)
|
||||||
|
parser.add_argument('--activate', action='store_true', help='Use this version as general US rates without expiry')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.xlsx:
|
||||||
|
if not args.output or args.db or args.output.exists():
|
||||||
|
parser.error('--xlsx requires a new --output path, without --db')
|
||||||
|
snapshot = export_snapshot(args.xlsx)
|
||||||
|
with args.output.open('x', encoding='utf-8') as target:
|
||||||
|
json.dump(snapshot, target, ensure_ascii=False, separators=(',', ':'))
|
||||||
|
print(json.dumps({key:len(snapshot[key]) for key in ('rates','warehouses','postcodes','documents')}))
|
||||||
|
return
|
||||||
|
if not args.db or not args.db.is_file() or not args.snapshot.is_file() or args.snapshot.stat().st_size > 20*1024*1024:
|
||||||
|
parser.error('Import requires an existing database and a private snapshot smaller than 20 MB')
|
||||||
|
snapshot = json.loads(args.snapshot.read_text(encoding='utf-8-sig'))
|
||||||
|
with closing(sqlite3.connect(args.db)) as connection, connection:
|
||||||
|
print(json.dumps(import_snapshot(connection, snapshot, activate=args.activate)))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+71
-31
@@ -1,5 +1,5 @@
|
|||||||
/* Customer workspace: navigation, welcome, conversation and composer. */
|
/* Customer workspace: navigation, welcome, conversation and composer. */
|
||||||
body { padding: 20px; height: 100dvh; overflow: hidden; }
|
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||||
.chat-container {
|
.chat-container {
|
||||||
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
||||||
overflow: hidden; position: relative; border: 1px solid var(--border);
|
overflow: hidden; position: relative; border: 1px solid var(--border);
|
||||||
@@ -42,9 +42,23 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.sidebar-footer { border-top: 1px solid var(--border); padding: 15px 7px 0; margin-top: 13px; white-space: nowrap; }
|
.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 { border: 0; color: var(--muted); background: transparent; padding: 5px 0; font-size: 11px; }
|
||||||
.clear-all-btn:hover { color: var(--danger); }
|
.clear-all-btn:hover { color: var(--danger); }
|
||||||
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
|
.customer-account { display: block; width: 100%; min-height: 40px; margin-bottom: 8px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; color: var(--brand-text); background: var(--surface); text-align: left; font-size: 13px; }
|
||||||
|
.customer-login-cta { display: block; margin-top: 12px; min-height: 40px; padding: 8px 18px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 14px; }
|
||||||
|
.customer-login-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 20px; background: rgba(18,33,56,.35); overflow-y: auto; }
|
||||||
|
.customer-login-dialog { width: min(380px,100%); max-height: 100%; overflow-y: auto; padding: 24px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
|
||||||
|
.customer-login-dialog h2 { margin: 0 0 20px; font-size: 20px; }
|
||||||
|
.customer-login-dialog label { display: block; margin: 14px 0 6px; font-size: 14px; }
|
||||||
|
.customer-login-dialog input { width: 100%; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 16px; }
|
||||||
|
.customer-login-dialog input:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); }
|
||||||
|
#customer-error, #order-lookup-error { margin-top: 12px; color: var(--danger); font-size: 14px; }
|
||||||
|
.customer-login-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
|
||||||
|
.customer-login-actions button { min-height: 44px; min-width: 76px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--secondary); }
|
||||||
|
#customer-submit, #order-lookup-submit { border-color: var(--brand); background: var(--brand); color: white; }
|
||||||
|
.order-reference-field { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.order-reference-field input { min-width: 0; flex: 1; }
|
||||||
|
#order-reference-copy { flex-shrink: 0; min-width: 60px; min-height: 44px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--brand-text); }
|
||||||
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
||||||
.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||||
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
|
.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-copy { min-width: 0; flex: 1; }
|
||||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||||
@@ -64,7 +78,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.ai-avatar svg { width: 27px; height: 27px; }
|
.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; }
|
.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 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-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 { 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-item:hover { border-color: #9ab6f5; background: var(--brand-soft); box-shadow: 0 3px 12px rgba(49, 95, 213, .04); }
|
||||||
@@ -72,7 +85,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.suggest-icon svg { width: 19px; height: 19px; }
|
.suggest-icon svg { width: 19px; height: 19px; }
|
||||||
.suggest-copy { min-width: 0; }
|
.suggest-copy { min-width: 0; }
|
||||||
.suggest-copy strong { display: block; font-size: 14px; font-weight: 600; }
|
.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; }
|
.suggest-arrow { margin-left: auto; color: var(--muted); font-size: 17px; }
|
||||||
.hot-questions { margin-top: 24px; }
|
.hot-questions { margin-top: 24px; }
|
||||||
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
|
.hot-questions h4, .recent-chat h4 { color: var(--secondary); font-size: 12px; font-weight: 600; margin-bottom: 10px; }
|
||||||
@@ -86,10 +98,17 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.recent-item:hover { color: var(--brand-text); }
|
.recent-item:hover { color: var(--brand-text); }
|
||||||
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
.recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; }
|
||||||
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); }
|
||||||
|
.composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
|
||||||
|
.composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||||
|
.voice-toggle, .voice-hold { display: none; }
|
||||||
|
.voice-panel { position: absolute; z-index: 35; left: 50%; bottom: calc(var(--composer-height, 150px) + 14px); transform: translateX(-50%); width: min(330px, calc(100% - 32px)); padding: 20px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); color: var(--brand-text); box-shadow: var(--shadow); text-align: center; pointer-events: none; }
|
||||||
|
.voice-panel p { margin: 9px 0; font-size: 13px; }
|
||||||
|
.voice-panel.is-cancelling { color: var(--danger); border-color: var(--danger); }
|
||||||
|
.voice-wave { display: block; font-size: 26px; letter-spacing: 7px; }
|
||||||
|
#voice-clock { font-size: 12px; color: var(--muted); }
|
||||||
.hot-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 10px; }
|
.hot-bar { 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 { 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; }
|
.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; }
|
.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 { 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); }
|
.input-area:focus-within { border-color: #7f9fe8; box-shadow: 0 0 0 3px var(--brand-soft); }
|
||||||
@@ -102,12 +121,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.human-system-message { margin: 8px 0; text-align: center; color: var(--muted); font-size: 12px; }
|
.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 { flex: 1; min-width: 0; min-height: 38px; max-height: 170px; padding: 9px 1px; border: 0; background: transparent; color: var(--text); font-size: 1em; line-height: 1.6; resize: none; outline: none; }
|
||||||
#input-text::placeholder { color: var(--muted); }
|
#input-text::placeholder { color: var(--muted); }
|
||||||
|
#input-text:placeholder-shown { white-space: nowrap; overflow: hidden; }
|
||||||
#send-button { min-width: 66px; height: 38px; flex-shrink: 0; padding: 0 15px; border: 0; border-radius: 8px; background: var(--brand); color: white; font-size: 12px; font-weight: 600; }
|
#send-button { 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:hover { background: var(--brand-hover); }
|
||||||
#send-button.stop { background: var(--danger); }
|
#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 { 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; }
|
.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; }
|
.message-author { display: flex; align-items: center; gap: 8px; min-height: 24px; margin-bottom: 4px; color: var(--secondary); font-size: 12px; font-weight: 600; }
|
||||||
@@ -124,6 +141,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.msg-actions { display: flex; gap: 5px; flex-wrap: wrap; padding-top: 12px; margin-top: 10px; border-top: 1px solid var(--border); }
|
.msg-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 { 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); }
|
.msg-action-btn:hover, .msg-action-btn.voted { background: var(--surface-hover); color: var(--brand-text); }
|
||||||
|
.vote-status { align-self: center; color: var(--muted); font-size: 11px; }
|
||||||
|
.vote-status.is-error { color: var(--danger); }
|
||||||
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
|
.error-bubble { border-color: #e9b4bb; background: var(--danger-soft); color: var(--danger); }
|
||||||
.err-line { margin-bottom: 9px; }
|
.err-line { margin-bottom: 9px; }
|
||||||
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
|
.retry-btn { border: 1px solid currentColor; border-radius: 6px; padding: 4px 9px; color: inherit; background: transparent; font-size: 11px; }
|
||||||
@@ -134,6 +153,8 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.robot-message.track-card { width: min(560px, 100%); max-width: 100%; background: var(--surface); padding: 16px 20px; }
|
.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-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 { font-weight: 650; font-size: 17px; letter-spacing: .02em; }
|
||||||
|
.order-reference { display: block; max-width: 100%; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--brand-text); font: inherit; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
|
||||||
|
.order-reference:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }
|
||||||
.track-no span { display: block; font-size: 10px; letter-spacing: 0; font-weight: 400; color: var(--muted); margin-bottom: 3px; }
|
.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-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 { position: relative; padding: 0 0 12px 22px; font-size: 13px; color: var(--secondary); }
|
||||||
@@ -151,6 +172,13 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.track-expand { width: 100%; margin-top: 3px; padding: 7px 10px; border: 0; border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
|
.track-expand { 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-expand:hover { background: var(--brand-soft); }
|
||||||
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
|
.track-card .msg-actions { margin-top: 8px; padding-top: 8px; }
|
||||||
|
.order-data-fields { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 7px 16px; margin: 14px 0; font-size: 13px; }
|
||||||
|
.order-data-fields dt { color: var(--muted); }
|
||||||
|
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.order-details-toggle { display: none; }
|
||||||
|
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
|
||||||
|
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
|
||||||
|
.order-tracking-status { margin: 0 0 12px; color: var(--brand-text); font-size: 13px; }
|
||||||
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
|
.to-bottom { 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.show { display: block; }
|
||||||
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
|
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
|
||||||
@@ -161,7 +189,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.attachment-file-icon { display: grid; place-items: center; flex-shrink: 0; width: 42px; height: 42px; font-size: 10px; font-weight: 650; border-radius: 7px; background: var(--brand-soft); color: var(--brand-text); }
|
.attachment-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 { 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 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 { 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); }
|
.attachment-remove:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
|
.composer-status { font-size: 12px; color: var(--secondary); padding: 7px 3px 0; }
|
||||||
@@ -173,7 +200,6 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.ai-welcome-card { padding-top: 0; }
|
.ai-welcome-card { padding-top: 0; }
|
||||||
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
|
.ai-avatar { width: 42px; height: 42px; margin-bottom: 12px; }
|
||||||
.welcome-eyebrow { margin-bottom: 6px; }
|
.welcome-eyebrow { margin-bottom: 6px; }
|
||||||
.ai-welcome-card p { margin-top: 8px; }
|
|
||||||
.suggest-grid { margin-top: 20px; gap: 10px; }
|
.suggest-grid { margin-top: 20px; gap: 10px; }
|
||||||
.suggest-item { min-height: 72px; padding: 13px 16px; }
|
.suggest-item { min-height: 72px; padding: 13px 16px; }
|
||||||
.hot-questions { margin-top: 18px; }
|
.hot-questions { margin-top: 18px; }
|
||||||
@@ -186,22 +212,38 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.composer { width: calc(100% - 50px); }
|
.composer { width: calc(100% - 50px); }
|
||||||
.service-label { display: none; }
|
.service-label { display: none; }
|
||||||
.suggest-item { padding: 13px; gap: 10px; }
|
.suggest-item { padding: 13px; gap: 10px; }
|
||||||
.suggest-copy small { font-size: 11px; }
|
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
body { padding: 0; }
|
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||||
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
|
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||||
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||||
.left-panel .brand { display: none; }
|
.left-panel .brand { display: none; }
|
||||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||||
.main-chat { width: 100%; }
|
.main-chat { width: 100%; }
|
||||||
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
|
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||||
#chatDom { padding: 24px 20px 15px; }
|
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||||
.ai-welcome-card { padding-top: 20px; }
|
.ai-welcome-card { padding-top: 20px; }
|
||||||
.composer { width: calc(100% - 40px); }
|
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||||
|
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||||
|
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||||
|
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||||
|
.is-composing .composer-footer { display: none; }
|
||||||
|
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||||
|
.history-search { height: 44px; }
|
||||||
|
.history-search-wrap svg { top: 14px; }
|
||||||
|
.composer-toolbar { margin-bottom: 8px; }
|
||||||
|
.hot-bar .hot-capsule { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.input-area { align-items: center; }
|
||||||
|
.input-area .emoji-control { display: flex; align-items: center; }
|
||||||
|
#upload-btn, #emoji-toggle, #send-button { display: inline-flex; align-items: center; justify-content: center; height: 44px; min-height: 44px; padding-top: 0; padding-bottom: 0; border-radius: 8px; line-height: 1; }
|
||||||
|
#input-text { min-height: 44px; padding-top: 10px; padding-bottom: 10px; line-height: 1.5; }
|
||||||
.user-message, .robot-message { max-width: 94%; }
|
.user-message, .robot-message { max-width: 94%; }
|
||||||
.del-history-btn { opacity: 1; }
|
.del-history-btn { opacity: 1; }
|
||||||
|
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; line-height: 1; }
|
||||||
|
.input-area.voice-mode #input-text, .input-area.voice-mode .emoji-control, .input-area.voice-mode #send-button { display: none; }
|
||||||
|
.input-area.voice-mode .voice-hold { display: block; flex: 1; min-width: 0; min-height: 44px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--text); font-size: 15px; font-weight: 600; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; }
|
||||||
|
.voice-hold.is-recording { color: var(--brand-text); background: var(--brand-soft); }
|
||||||
}
|
}
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.header-kicker { font-size: 9px; }
|
.header-kicker { font-size: 9px; }
|
||||||
@@ -214,12 +256,10 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.ai-avatar { display: none; }
|
.ai-avatar { display: none; }
|
||||||
.ai-welcome-card { padding-top: 12px; }
|
.ai-welcome-card { padding-top: 12px; }
|
||||||
.ai-welcome-card h3 { font-size: 23px; }
|
.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-grid { gap: 9px; margin-top: 22px; }
|
||||||
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 114px; padding: 13px; gap: 9px; }
|
.suggest-item { flex-direction: column; align-items: flex-start; justify-content: flex-start; min-height: 88px; padding: 13px; gap: 9px; }
|
||||||
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
|
.suggest-icon { width: 32px; height: 32px; border-radius: 8px; }
|
||||||
.suggest-copy strong { font-size: 13px; }
|
.suggest-copy strong { font-size: 13px; }
|
||||||
.suggest-copy small { font-size: 11px; }
|
|
||||||
.suggest-arrow { display: none; }
|
.suggest-arrow { display: none; }
|
||||||
.hot-questions { margin-top: 22px; }
|
.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-wrap { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 3px; }
|
||||||
@@ -227,21 +267,21 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
|||||||
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
.hot-capsule { font-size: 10px; padding: 6px 8px; }
|
||||||
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
#recentChatWrap { grid-template-columns: 1fr; gap: 0; }
|
||||||
.composer { width: calc(100% - 24px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
.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 { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 5px; }
|
||||||
.hot-bar-label { display: none; }
|
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; padding: 6px 9px; font-size: 11px; }
|
||||||
.hot-bar .hot-capsule { white-space: nowrap; flex-shrink: 0; }
|
|
||||||
.input-area { gap: 7px; padding: 7px; }
|
.input-area { gap: 7px; padding: 7px; }
|
||||||
#send-button { min-width: 57px; padding: 0 12px; }
|
#send-button { min-width: 57px; padding: 0 12px; }
|
||||||
#upload-btn { flex-basis: 36px; height: 42px; }
|
#upload-btn { flex-basis: 36px; }
|
||||||
#input-text { font-size: 16px; }
|
#input-text { font-size: 16px; }
|
||||||
#send-button { min-height: 42px; }
|
|
||||||
.attachment-remove { width: 40px; height: 40px; }
|
.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-text { grid-template-columns: 1fr; gap: 2px; }
|
||||||
.track-place { grid-column: 1; }
|
.track-place { grid-column: 1; }
|
||||||
.robot-message.track-card { padding: 16px; }
|
.robot-message.track-card { padding: 16px; }
|
||||||
.track-no { font-size: 15px; }
|
.track-head { align-items: flex-start; gap: 10px; }
|
||||||
|
.track-no { min-width: 0; flex: 1; font-size: 15px; }
|
||||||
|
.order-details-toggle { display: block; width: 100%; min-height: 44px; margin: 0 0 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-soft); color: var(--brand-text); font-size: 12px; }
|
||||||
|
.order-data-fields.is-collapsed { display: none; }
|
||||||
|
.track-expand { min-height: 44px; }
|
||||||
|
.msg-action-btn { min-height: 40px; }
|
||||||
.to-bottom { right: 15px; }
|
.to-bottom { right: 15px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ svg { display: block; 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 { 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 { border: 0; border-radius: 6px; background: transparent; font-size: 22px; padding: 6px; }
|
||||||
.emoji-picker button:hover { background: var(--surface-hover); }
|
.emoji-picker button:hover { background: var(--surface-hover); }
|
||||||
|
.attachment-unavailable { display: block; max-width: 260px; padding: 12px; border: 1px dashed var(--border); border-radius: 8px; background: var(--surface-soft); color: var(--secondary); font-size: 12px; line-height: 1.6; }
|
||||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
.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; }
|
.empty-caption { color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
|||||||
+3
-2
@@ -11,6 +11,7 @@ var typeTimer = null;
|
|||||||
var toolsOpen = false;
|
var toolsOpen = false;
|
||||||
var toolsOpener = null;
|
var toolsOpener = null;
|
||||||
var replyInput = document.getElementById("replyText");
|
var replyInput = document.getElementById("replyText");
|
||||||
|
SupportUI.initAttachments(document.getElementById('chatBox'), '图片暂时无法访问,请客户重新发送附件。');
|
||||||
|
|
||||||
function escapeText(value){
|
function escapeText(value){
|
||||||
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
|
return String(value == null ? "" : value).replace(/[&<>"']/g, function(character){
|
||||||
@@ -191,8 +192,8 @@ function appendMsg(message){
|
|||||||
}
|
}
|
||||||
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
div.className = "msg " + (message.side === "customer" ? "cust" : "agent");
|
||||||
let content = escapeText(message.content);
|
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="/api/staff/uploads/$1" target="_blank" rel="noopener"><img src="/api/staff/uploads/$1" alt="客户附件" class="message-image"></a>');
|
||||||
content = content.replace(/\[文件\](\/uploads\/[A-Za-z0-9_.%-]+)/g, '<a href="$1" target="_blank" rel="noopener" class="message-file">↗ 查看附件</a>');
|
content = content.replace(/\[文件\]\/uploads\/([A-Za-z0-9_.%-]+)/g, '<a href="/api/staff/uploads/$1" target="_blank" rel="noopener" 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>';
|
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>';
|
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"){
|
if(message.side === "agent"){
|
||||||
|
|||||||
+713
-238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
// Customer login has no relationship to the protected staff workspace.
|
||||||
|
var CustomerLogin = {
|
||||||
|
create(options){
|
||||||
|
const overlay=document.getElementById('customer-login');
|
||||||
|
const form=document.getElementById('customer-login-form');
|
||||||
|
const username=document.getElementById('customer-username');
|
||||||
|
const password=document.getElementById('customer-password');
|
||||||
|
const submit=document.getElementById('customer-submit');
|
||||||
|
const cancel=document.getElementById('customer-cancel');
|
||||||
|
const error=document.getElementById('customer-error');
|
||||||
|
const account=document.getElementById('customer-account');
|
||||||
|
let user, pending=null, opener=null, busy=false, version=0;
|
||||||
|
overlay.hidden=true;
|
||||||
|
function status(message){ error.textContent=message || ''; error.hidden=!message; }
|
||||||
|
function render(){
|
||||||
|
account.textContent=user ? user.name+' · '+options.text('customerLogout') : options.text('customerLogin');
|
||||||
|
submit.textContent=options.text(busy ? 'customerSigningIn' : 'customerLogin');
|
||||||
|
cancel.textContent=options.text('customerCancel');
|
||||||
|
document.getElementById('customer-login-title').textContent=options.text('customerLogin');
|
||||||
|
document.getElementById('customer-username-label').textContent=options.text('customerUsername');
|
||||||
|
document.getElementById('customer-password-label').textContent=options.text('customerPassword');
|
||||||
|
username.placeholder=options.text('customerUsernamePlaceholder');
|
||||||
|
password.placeholder=options.text('customerPasswordPlaceholder');
|
||||||
|
}
|
||||||
|
function update(next){
|
||||||
|
const previous=user;
|
||||||
|
user=next;
|
||||||
|
render();
|
||||||
|
if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){
|
||||||
|
options.onChange(user,previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function request(path, data){
|
||||||
|
const controller=new AbortController();
|
||||||
|
const timer=setTimeout(()=>controller.abort(),10000);
|
||||||
|
try{
|
||||||
|
const response=await options.fetch(path,{method:data === undefined ? 'GET' : 'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},body:data === undefined ? undefined : JSON.stringify(data),signal:controller.signal});
|
||||||
|
const result=await response.json();
|
||||||
|
if(!response.ok || !result.ok){
|
||||||
|
const failure=new Error(result.error || result.e || options.text('customerLoginFailed'));
|
||||||
|
failure.publicMessage=true;throw failure;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}finally{ clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
async function refresh(){
|
||||||
|
if(busy) return;
|
||||||
|
const token=++version;
|
||||||
|
try{
|
||||||
|
const result=await request('/api/customer/session');
|
||||||
|
if(token === version) update(result.authenticated && result.user ? result.user : null);
|
||||||
|
}catch(error){} // Query endpoints still enforce login if status cannot refresh.
|
||||||
|
}
|
||||||
|
function close(){
|
||||||
|
overlay.hidden=true;
|
||||||
|
password.value='';
|
||||||
|
pending=null;
|
||||||
|
if(opener && opener.isConnected) opener.focus({preventScroll:true});
|
||||||
|
opener=null;
|
||||||
|
}
|
||||||
|
function open(onSuccess){
|
||||||
|
if(busy) return;
|
||||||
|
pending=typeof onSuccess === 'function' ? onSuccess : null;
|
||||||
|
opener=document.activeElement;
|
||||||
|
status(''); password.value=''; overlay.hidden=false;
|
||||||
|
if(options.onOpen) options.onOpen();
|
||||||
|
render(); username.focus({preventScroll:true});
|
||||||
|
}
|
||||||
|
function setBusy(value){
|
||||||
|
busy=value; submit.disabled=value; cancel.disabled=value; account.disabled=value;
|
||||||
|
username.disabled=value; password.disabled=value; render();
|
||||||
|
}
|
||||||
|
form.addEventListener('submit',async function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
if(busy) return;
|
||||||
|
const token=++version;
|
||||||
|
setBusy(true);status('');
|
||||||
|
try{
|
||||||
|
const result=await request('/api/customer/login',{username:username.value,password:password.value});
|
||||||
|
if(token !== version) return;
|
||||||
|
update(result.user);
|
||||||
|
const resume=pending;close();
|
||||||
|
if(resume) await resume();
|
||||||
|
}catch(failure){
|
||||||
|
if(token === version) status(failure.publicMessage ? failure.message : options.text('customerLoginFailed'));
|
||||||
|
}finally{ if(token === version) setBusy(false); }
|
||||||
|
});
|
||||||
|
cancel.onclick=function(){ if(!busy) close(); };
|
||||||
|
overlay.addEventListener('click',function(event){ if(event.target === overlay && !busy) close(); });
|
||||||
|
overlay.addEventListener('keydown',function(event){
|
||||||
|
if(event.key === 'Escape' && !busy){ event.preventDefault();close(); }
|
||||||
|
if(event.key === 'Tab'){
|
||||||
|
const controls=[username,password,submit,cancel].filter(control=>!control.disabled);
|
||||||
|
if(!controls.length){ event.preventDefault();return; }
|
||||||
|
const index=controls.indexOf(document.activeElement);
|
||||||
|
const next=(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length;
|
||||||
|
event.preventDefault();controls[next].focus({preventScroll:true});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
account.onclick=async function(){
|
||||||
|
if(busy) return;
|
||||||
|
if(!user){ open(); return; }
|
||||||
|
const token=++version;setBusy(true);
|
||||||
|
try{
|
||||||
|
await request('/api/customer/logout',{});
|
||||||
|
if(token === version) update(null);
|
||||||
|
}catch(failure){ if(options.onError) options.onError(options.text('customerLogoutFailed')); }
|
||||||
|
finally{ if(token === version) setBusy(false); }
|
||||||
|
};
|
||||||
|
window.addEventListener('focus',refresh);
|
||||||
|
document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); });
|
||||||
|
render();
|
||||||
|
return {open:open,refresh:refresh,render:render,require:async function(resume, canContinue){
|
||||||
|
if(busy) return;
|
||||||
|
await refresh();
|
||||||
|
if(canContinue && !canContinue(user)) return;
|
||||||
|
if(user){ await resume(user); }else open(function(){ return resume(user); });
|
||||||
|
},viewport:function(height,top){
|
||||||
|
overlay.style.height=height || ''; overlay.style.top=top || '';
|
||||||
|
overlay.style.bottom=height ? 'auto' : '';
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Text drafts stay in this visitor's browser, independently for each conversation.
|
||||||
|
var ChatDrafts = {
|
||||||
|
create(options){
|
||||||
|
const scope = /^[0-9a-f]{64}$/.test(options.scope || '') ? options.scope : null;
|
||||||
|
const prefix = scope ? 'baiyue-draft-v1:' + scope + ':' : null;
|
||||||
|
const activeKey = scope ? 'baiyue-active-chat-v1:' + scope : null;
|
||||||
|
const memory = new Map();
|
||||||
|
const dirty = new Set();
|
||||||
|
let current = 'new';
|
||||||
|
function id(value){
|
||||||
|
if(value === null || value === 'new') return 'new';
|
||||||
|
const key = String(value);
|
||||||
|
return /^[1-9][0-9]{0,18}$/.test(key) ? key : null;
|
||||||
|
}
|
||||||
|
function storage(name){
|
||||||
|
try{ return options[name](); }catch(error){ return null; }
|
||||||
|
}
|
||||||
|
function read(value){
|
||||||
|
const key = id(value);
|
||||||
|
if(!key) return '';
|
||||||
|
if(dirty.has(key)) return memory.get(key) || '';
|
||||||
|
if(prefix){
|
||||||
|
try{
|
||||||
|
const saved = storage('storage').getItem(prefix + key);
|
||||||
|
if(saved !== null){
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
if(data && data.version === 1 && typeof data.text === 'string'){
|
||||||
|
memory.set(key, data.text);
|
||||||
|
return data.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memory.delete(key);
|
||||||
|
return '';
|
||||||
|
}catch(error){}
|
||||||
|
}
|
||||||
|
return memory.get(key) || '';
|
||||||
|
}
|
||||||
|
function write(value, text){
|
||||||
|
const key = id(value);
|
||||||
|
if(!key || typeof text !== 'string') return false;
|
||||||
|
if(text) memory.set(key, text); else memory.delete(key);
|
||||||
|
dirty.add(key);
|
||||||
|
if(!prefix) return false;
|
||||||
|
try{
|
||||||
|
const target = storage('storage');
|
||||||
|
if(text) target.setItem(prefix + key, JSON.stringify({version:1,text:text}));
|
||||||
|
else target.removeItem(prefix + key);
|
||||||
|
dirty.delete(key);
|
||||||
|
return true;
|
||||||
|
}catch(error){ return false; }
|
||||||
|
}
|
||||||
|
function active(value){
|
||||||
|
if(arguments.length){
|
||||||
|
current = id(value) || 'new';
|
||||||
|
if(activeKey) try{ storage('tabStorage').setItem(activeKey, current); }catch(error){}
|
||||||
|
}else if(activeKey){
|
||||||
|
try{ const saved=storage('tabStorage').getItem(activeKey); if(saved !== null) current = id(saved) || current; }catch(error){}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
function clear(){
|
||||||
|
memory.clear();
|
||||||
|
dirty.clear();
|
||||||
|
if(prefix){
|
||||||
|
try{
|
||||||
|
const target = storage('storage');
|
||||||
|
for(let index = target.length - 1; index >= 0; index--){
|
||||||
|
const key = target.key(index);
|
||||||
|
if(key && key.startsWith(prefix)) target.removeItem(key);
|
||||||
|
}
|
||||||
|
}catch(error){}
|
||||||
|
}
|
||||||
|
active('new');
|
||||||
|
}
|
||||||
|
return {read:read,write:write,active:active,remove:value=>write(value,''),clear:clear};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// One lookup form for shortcuts and saved order cards; no draft or chat ownership changes.
|
||||||
|
var OrderLookup = {
|
||||||
|
create(options){
|
||||||
|
const overlay=document.getElementById('order-lookup');
|
||||||
|
const form=document.getElementById('order-lookup-form');
|
||||||
|
const input=document.getElementById('order-reference');
|
||||||
|
const label=document.getElementById('order-reference-label');
|
||||||
|
const title=document.getElementById('order-lookup-title');
|
||||||
|
const submit=document.getElementById('order-lookup-submit');
|
||||||
|
const cancel=document.getElementById('order-lookup-cancel');
|
||||||
|
const copy=document.getElementById('order-reference-copy');
|
||||||
|
const error=document.getElementById('order-lookup-error');
|
||||||
|
let kind='inbound', opener=null, busy=false, copying=false, copyState='', version=0, timer=null;
|
||||||
|
overlay.hidden=true;
|
||||||
|
function valid(value){
|
||||||
|
return /^[A-Za-z0-9][A-Za-z0-9_-]{5,63}$/.test(value) && (value.match(/[0-9]/g) || []).length >= 3;
|
||||||
|
}
|
||||||
|
function status(message){ error.textContent=message || ''; error.hidden=!message; }
|
||||||
|
function render(){
|
||||||
|
title.textContent=options.text(kind === 'inbound' ? 'lookupTitle' : 'lookupOrderTitle');
|
||||||
|
label.textContent=options.text(kind === 'inbound' ? 'order_inbound' : 'orderNo');
|
||||||
|
input.placeholder=options.text(kind === 'inbound' ? 'lookupPlaceholder' : 'lookupOrderPlaceholder');
|
||||||
|
submit.textContent=options.text(busy ? 'lookupSending' : 'lookupSubmit');
|
||||||
|
cancel.textContent=options.text('customerCancel');
|
||||||
|
copy.textContent=options.text(copyState || 'copy');
|
||||||
|
submit.disabled=busy; cancel.disabled=busy; input.disabled=busy;
|
||||||
|
copy.disabled=busy || copying || !valid(input.value.trim());
|
||||||
|
}
|
||||||
|
function clearTimer(){ if(timer !== null) clearTimeout(timer); timer=null; }
|
||||||
|
function close(){
|
||||||
|
version+=1;clearTimer();overlay.hidden=true;
|
||||||
|
busy=false;copying=false;copyState='';input.value='';status('');render();
|
||||||
|
if(opener && opener.isConnected) opener.focus({preventScroll:true});
|
||||||
|
opener=null;
|
||||||
|
}
|
||||||
|
function open(reference, referenceKind, source){
|
||||||
|
version+=1;clearTimer();kind=referenceKind === 'order' ? 'order' : 'inbound';
|
||||||
|
busy=false;copying=false;copyState='';
|
||||||
|
opener=source || document.activeElement;input.value=reference || '';status('');render();overlay.hidden=false;
|
||||||
|
if(options.onOpen) options.onOpen();
|
||||||
|
input.focus({preventScroll:true});
|
||||||
|
}
|
||||||
|
input.addEventListener('input',function(){ clearTimer();copyState='';status('');render(); });
|
||||||
|
copy.onclick=async function(){
|
||||||
|
const reference=input.value.trim(), token=version;
|
||||||
|
if(busy || copying || !valid(reference)) return;
|
||||||
|
copying=true;copyState='';status('');render();
|
||||||
|
const copied=await SupportUI.copyText(reference);
|
||||||
|
if(token !== version) return;
|
||||||
|
copying=false;
|
||||||
|
if(input.value.trim() === reference){
|
||||||
|
copyState=copied ? 'copied' : 'copyFailed';
|
||||||
|
if(!copied) status(options.text('copyFailed'));
|
||||||
|
timer=setTimeout(function(){copyState='';timer=null;render();},copied ? 1200 : 3000);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
form.addEventListener('submit',async function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
if(busy) return;
|
||||||
|
const reference=input.value.trim(), token=version;
|
||||||
|
if(!valid(reference)){status(options.text('lookupInvalid'));input.focus({preventScroll:true});return;}
|
||||||
|
const blocked=options.canQuery();
|
||||||
|
if(blocked){status(blocked);return;}
|
||||||
|
busy=true;status('');render();
|
||||||
|
try{
|
||||||
|
const accepted=await options.query(reference,kind);
|
||||||
|
if(token !== version) return;
|
||||||
|
if(accepted) close();else status(options.text('lookupFailed'));
|
||||||
|
}catch(error){if(token === version) status(options.text('lookupFailed'));}
|
||||||
|
finally{if(token === version){busy=false;render();}}
|
||||||
|
});
|
||||||
|
cancel.onclick=function(){if(!busy) close();};
|
||||||
|
overlay.addEventListener('click',function(event){if(event.target === overlay && !busy) close();});
|
||||||
|
overlay.addEventListener('keydown',function(event){
|
||||||
|
if(event.key === 'Escape' && !busy){event.preventDefault();close();return;}
|
||||||
|
if(event.key === 'Tab'){
|
||||||
|
const controls=[input,copy,cancel,submit].filter(control=>!control.disabled);
|
||||||
|
event.preventDefault();
|
||||||
|
if(controls.length){
|
||||||
|
const index=controls.indexOf(document.activeElement);
|
||||||
|
controls[(index+(event.shiftKey ? -1 : 1)+controls.length)%controls.length].focus({preventScroll:true});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
return {open:open,cancel:close,render:render,viewport:function(height,top){
|
||||||
|
overlay.style.height=height || '';overlay.style.top=top || '';overlay.style.bottom=height ? 'auto' : '';
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
};
|
||||||
+51
-9
@@ -1,5 +1,38 @@
|
|||||||
// Shared controls for the customer page and service workspace.
|
// Shared controls for the customer page and service workspace.
|
||||||
var SupportUI = {
|
var SupportUI = {
|
||||||
|
async copyText(text){
|
||||||
|
try{
|
||||||
|
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}catch(error){}
|
||||||
|
const field = document.createElement('textarea');
|
||||||
|
field.value=text; field.readOnly=true;
|
||||||
|
field.style.position='fixed'; field.style.opacity='0';
|
||||||
|
const previous=document.activeElement;
|
||||||
|
document.body.appendChild(field);
|
||||||
|
try{
|
||||||
|
field.focus({preventScroll:true}); field.select();
|
||||||
|
return Boolean(document.execCommand('copy'));
|
||||||
|
}catch(error){ return false; }
|
||||||
|
finally{
|
||||||
|
field.remove();
|
||||||
|
if(previous && previous.isConnected) previous.focus({preventScroll:true});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initAttachments(container, label){
|
||||||
|
container.addEventListener('error', function(event){
|
||||||
|
const image = event.target;
|
||||||
|
if(!image || image.tagName !== 'IMG' || !image.classList.contains('message-image') || image.dataset.unavailable) return;
|
||||||
|
image.dataset.unavailable = '1';
|
||||||
|
image.hidden = true;
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'attachment-unavailable';
|
||||||
|
hint.textContent = typeof label === 'function' ? label() : label;
|
||||||
|
image.after(hint);
|
||||||
|
}, true);
|
||||||
|
},
|
||||||
async enableNotifications(button){
|
async enableNotifications(button){
|
||||||
if(!('Notification' in window)) return;
|
if(!('Notification' in window)) return;
|
||||||
try{
|
try{
|
||||||
@@ -15,6 +48,7 @@ var SupportUI = {
|
|||||||
}catch(error){}
|
}catch(error){}
|
||||||
},
|
},
|
||||||
addRating(container, sid, labels){
|
addRating(container, sid, labels){
|
||||||
|
const request = labels.fetch || fetch;
|
||||||
const box = document.createElement('section');
|
const box = document.createElement('section');
|
||||||
box.className = 'service-rating';
|
box.className = 'service-rating';
|
||||||
const question = document.createElement('p');
|
const question = document.createElement('p');
|
||||||
@@ -40,7 +74,7 @@ var SupportUI = {
|
|||||||
sending = true;
|
sending = true;
|
||||||
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
Array.from(choices.children).forEach(function(item){ item.disabled = true; });
|
||||||
try{
|
try{
|
||||||
const response = await fetch('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
const response = await request('/api/rate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sid:sid, stars:stars, comment:''})});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
if(!response.ok || !data.ok) throw new Error('Rating failed');
|
||||||
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
box.textContent = labels.thanks+' '+ '★'.repeat(stars);
|
||||||
@@ -57,6 +91,10 @@ var SupportUI = {
|
|||||||
},
|
},
|
||||||
initEmoji(button, panel, input, onInsert){
|
initEmoji(button, panel, input, onInsert){
|
||||||
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
const emojis = ['😊', '😂', '👍', '❤️', '🙏', '😅', '😢', '🎉', '📦', '🚚'];
|
||||||
|
function close(){
|
||||||
|
panel.hidden = true;
|
||||||
|
button.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
button.onclick = function(){
|
button.onclick = function(){
|
||||||
panel.hidden = !panel.hidden;
|
panel.hidden = !panel.hidden;
|
||||||
button.setAttribute('aria-expanded', String(!panel.hidden));
|
button.setAttribute('aria-expanded', String(!panel.hidden));
|
||||||
@@ -69,20 +107,24 @@ var SupportUI = {
|
|||||||
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
const start = input.selectionStart == null ? input.value.length : input.selectionStart;
|
||||||
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
const end = input.selectionEnd == null ? start : input.selectionEnd;
|
||||||
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
input.value = input.value.slice(0, start) + emoji + input.value.slice(end);
|
||||||
panel.hidden = true;
|
close();
|
||||||
button.setAttribute('aria-expanded', 'false');
|
input.focus({preventScroll:true});
|
||||||
input.focus();
|
|
||||||
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
input.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||||
if(onInsert) onInsert();
|
if(onInsert) onInsert();
|
||||||
};
|
};
|
||||||
panel.appendChild(choice);
|
panel.appendChild(choice);
|
||||||
});
|
});
|
||||||
panel.addEventListener('keydown', function(event){
|
['pointerdown','mousedown'].forEach(function(name){
|
||||||
if(event.key === 'Escape'){
|
document.addEventListener(name, function(event){
|
||||||
panel.hidden = true;
|
if(!panel.hidden && !panel.contains(event.target) && !button.contains(event.target)) close();
|
||||||
button.setAttribute('aria-expanded', 'false');
|
});
|
||||||
button.focus();
|
});
|
||||||
|
document.addEventListener('keydown', function(event){
|
||||||
|
if(event.key === 'Escape' && !panel.hidden){
|
||||||
|
close();
|
||||||
|
button.focus({preventScroll:true});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return {close:close};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// A local random guest key restores this browser's history, never customer order identity.
|
||||||
|
var VisitorSession = (function(){
|
||||||
|
const storageName = 'baiyue-visitor-key';
|
||||||
|
const nativeFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : fetch;
|
||||||
|
let ready = null, established = false;
|
||||||
|
function localKey(){
|
||||||
|
try{
|
||||||
|
const saved = localStorage.getItem(storageName);
|
||||||
|
if(saved && /^[0-9a-f]{64}$/.test(saved)) return saved;
|
||||||
|
const crypto = window.crypto;
|
||||||
|
if(!crypto || !crypto.getRandomValues) return null;
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
const key = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('');
|
||||||
|
localStorage.setItem(storageName, key);
|
||||||
|
return key;
|
||||||
|
}catch(error){ return null; } // HttpOnly cookie can still retain this guest when storage is disabled.
|
||||||
|
}
|
||||||
|
function ensure(){
|
||||||
|
if(ready) return ready;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 10000);
|
||||||
|
ready = nativeFetch('/api/visitor/session', {method:'POST',credentials:'same-origin',
|
||||||
|
headers:{'Content-Type':'application/json'},body:JSON.stringify({key:localKey()}),signal:controller.signal})
|
||||||
|
.then(async response => {
|
||||||
|
const data = await response.json();
|
||||||
|
if(!response.ok || !data.ok || typeof data.key !== 'string' || !/^[0-9a-f]{64}$/.test(data.key)){
|
||||||
|
throw new Error('访客会话暂时无法建立,请刷新后重试。');
|
||||||
|
}
|
||||||
|
try{ localStorage.setItem(storageName, data.key); }catch(error){}
|
||||||
|
established = true;
|
||||||
|
}).catch(error => { ready = null; established = false; throw error; }).finally(() => clearTimeout(timer));
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
function request(path, options){
|
||||||
|
const init = Object.assign({}, options, {credentials:'same-origin'});
|
||||||
|
function send(){
|
||||||
|
if(init.signal && init.signal.aborted) return Promise.reject(Object.assign(new Error('Aborted'), {name:'AbortError'}));
|
||||||
|
const session = ready;
|
||||||
|
return nativeFetch(path, init).then(async response => {
|
||||||
|
if(response.status !== 401) return response;
|
||||||
|
if(ready === session){ ready = null; established = false; }
|
||||||
|
await ensure();
|
||||||
|
return nativeFetch(path, init);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return established ? send() : ensure().then(send);
|
||||||
|
}
|
||||||
|
return {fetch:request,localKey:localKey};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Short recordings use the same server ASR in mobile browsers and embedded browsers.
|
||||||
|
var ChatVoice = {
|
||||||
|
create(options){
|
||||||
|
const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch);
|
||||||
|
const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options;
|
||||||
|
let session = null, mode = false;
|
||||||
|
const active = current => session === current;
|
||||||
|
function stopTracks(current){
|
||||||
|
if(current.stream) current.stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
function clearTimers(current){
|
||||||
|
clearTimeout(current.limitTimer);
|
||||||
|
clearTimeout(current.permissionTimer);
|
||||||
|
clearInterval(current.clockTimer);
|
||||||
|
}
|
||||||
|
function render(){
|
||||||
|
toggle.setAttribute('aria-pressed', String(mode));
|
||||||
|
toggle.setAttribute('aria-label', text(mode ? 'voiceKeyboard' : 'voiceInput'));
|
||||||
|
toggle.title = text(mode ? 'voiceKeyboard' : 'voiceInput');
|
||||||
|
toggle.textContent = mode ? '⌨' : '🎙';
|
||||||
|
inputArea.classList.toggle('voice-mode', mode);
|
||||||
|
hold.textContent = text(session ? 'voiceRelease' : 'voiceHold');
|
||||||
|
hold.classList.toggle('is-recording', Boolean(session));
|
||||||
|
panel.hidden = !session;
|
||||||
|
}
|
||||||
|
function cancel(message){
|
||||||
|
const current = session;
|
||||||
|
session = null;
|
||||||
|
if(current){
|
||||||
|
current.controller.abort();
|
||||||
|
clearTimers(current);
|
||||||
|
if(current.recorder && current.recorder.state !== 'inactive'){
|
||||||
|
try{ current.recorder.stop(); }catch(error){}
|
||||||
|
}
|
||||||
|
stopTracks(current);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
if(message) status(text(message), true);
|
||||||
|
}
|
||||||
|
function setMode(on){
|
||||||
|
cancel();
|
||||||
|
mode = on;
|
||||||
|
render();
|
||||||
|
if(on && input.blur) input.blur();
|
||||||
|
}
|
||||||
|
function supported(){
|
||||||
|
if(window.isSecureContext === false){ status(text('voiceHttps'), true); return false; }
|
||||||
|
if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder){
|
||||||
|
status(text('voiceUnsupported'), true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function jsonRequest(current, url, init, timeout){
|
||||||
|
const timer = setTimeout(function(){ current.timedOut = true; current.controller.abort(); }, timeout);
|
||||||
|
try{
|
||||||
|
const response = await fetch(url, Object.assign({}, init, {signal:current.controller.signal}));
|
||||||
|
const data = await response.json();
|
||||||
|
if(!response.ok) throw new Error(data.error || data.e || text('voiceFailed'));
|
||||||
|
return data;
|
||||||
|
}finally{ clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
async function transcribe(current){
|
||||||
|
if(!active(current)) return;
|
||||||
|
if(!current.chunks.length){ cancel('voiceEmpty'); return; }
|
||||||
|
current.phase = 'transcribing';
|
||||||
|
label.textContent = text('voiceTranscribing');
|
||||||
|
const blob = new Blob(current.chunks, {type:current.recorder.mimeType || current.mime});
|
||||||
|
if(!blob.size || blob.size > current.maxBytes){ cancel('voiceTooLarge'); return; }
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('audio', blob, 'recording');
|
||||||
|
form.append('duration', String(Math.min(60, current.duration / 1000)));
|
||||||
|
form.append('language', options.language());
|
||||||
|
try{
|
||||||
|
const data = await jsonRequest(current, '/api/speech/transcribe', {method:'POST',body:form}, 40000);
|
||||||
|
if(!active(current)) return;
|
||||||
|
if(current.context !== options.context()){ cancel(); return; }
|
||||||
|
if(!data.ok || typeof data.text !== 'string' || !data.text.trim()){
|
||||||
|
cancel('voiceEmpty'); return;
|
||||||
|
}
|
||||||
|
session = null;
|
||||||
|
render();
|
||||||
|
await options.onText(data.text.trim());
|
||||||
|
}catch(error){
|
||||||
|
if(!active(current)) return;
|
||||||
|
const message = current.timedOut ? text('voiceTimeout') : error.message || text('voiceFailed');
|
||||||
|
cancel();
|
||||||
|
status(message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function finish(current, cancelled){
|
||||||
|
if(!active(current) || !current.holding) return;
|
||||||
|
current.holding = false;
|
||||||
|
if(cancelled || current.cancelled){ cancel(); status(text('voiceCancelled')); return; }
|
||||||
|
if(current.phase !== 'recording'){ cancel('voiceTooShort'); return; }
|
||||||
|
current.duration = Date.now() - current.startedAt;
|
||||||
|
if(current.duration < 400){ cancel('voiceTooShort'); return; }
|
||||||
|
current.phase = 'stopping';
|
||||||
|
label.textContent = text('voiceTranscribing');
|
||||||
|
clearTimers(current);
|
||||||
|
try{ current.recorder.stop(); }
|
||||||
|
catch(error){ cancel('voiceFailed'); }
|
||||||
|
finally{ stopTracks(current); }
|
||||||
|
}
|
||||||
|
async function start(id, y){
|
||||||
|
if(session || !mode) return;
|
||||||
|
const reason = options.canStart();
|
||||||
|
if(reason){ status(reason, true); return; }
|
||||||
|
if(!supported()) return;
|
||||||
|
const current = {id:id,startY:y,holding:true,chunks:[],bytes:0,phase:'preparing',
|
||||||
|
controller:new AbortController(),context:options.context()};
|
||||||
|
session = current;
|
||||||
|
label.textContent = text('voicePreparing');
|
||||||
|
clock.textContent = '';
|
||||||
|
panel.classList.remove('is-cancelling');
|
||||||
|
status('');
|
||||||
|
render();
|
||||||
|
try{
|
||||||
|
const config = await jsonRequest(current, '/api/speech/config', {}, 5000);
|
||||||
|
if(!active(current)) return;
|
||||||
|
if(!config.enabled){ cancel('voiceUnavailable'); return; }
|
||||||
|
current.maxBytes = Math.min(Number(config.max_bytes) || 5242880, 5242880);
|
||||||
|
current.permissionTimer = setTimeout(function(){ if(active(current)) cancel('voiceTimeout'); }, 15000);
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true},video:false});
|
||||||
|
clearTimeout(current.permissionTimer);
|
||||||
|
if(!active(current)){ stream.getTracks().forEach(track => track.stop()); return; }
|
||||||
|
current.stream = stream;
|
||||||
|
const formats = ['audio/webm;codecs=opus','audio/mp4','audio/webm','audio/ogg;codecs=opus'];
|
||||||
|
current.mime = window.MediaRecorder.isTypeSupported
|
||||||
|
? formats.find(mime => window.MediaRecorder.isTypeSupported(mime)) || '' : '';
|
||||||
|
current.recorder = new window.MediaRecorder(stream, Object.assign({audioBitsPerSecond:64000},
|
||||||
|
current.mime ? {mimeType:current.mime} : {}));
|
||||||
|
current.recorder.ondataavailable = function(event){
|
||||||
|
if(!active(current) || !event.data || !event.data.size) return;
|
||||||
|
current.bytes += event.data.size;
|
||||||
|
if(current.bytes > current.maxBytes){ cancel('voiceTooLarge'); return; }
|
||||||
|
current.chunks.push(event.data);
|
||||||
|
};
|
||||||
|
current.recorder.onerror = function(){ if(active(current)) cancel('voiceFailed'); };
|
||||||
|
current.recorder.onstop = function(){
|
||||||
|
if(!active(current)) return;
|
||||||
|
if(current.phase === 'stopping') transcribe(current);
|
||||||
|
else cancel('voiceFailed');
|
||||||
|
};
|
||||||
|
current.recorder.start(250);
|
||||||
|
current.startedAt = Date.now();
|
||||||
|
current.phase = 'recording';
|
||||||
|
label.textContent = text('voiceCancelTip');
|
||||||
|
clock.textContent = '0 / 60 s';
|
||||||
|
current.clockTimer = setInterval(function(){
|
||||||
|
clock.textContent = Math.min(60, Math.floor((Date.now()-current.startedAt)/1000)) + ' / 60 s';
|
||||||
|
}, 250);
|
||||||
|
current.limitTimer = setTimeout(function(){ finish(current, false); }, 60000);
|
||||||
|
}catch(error){
|
||||||
|
if(!active(current)) return;
|
||||||
|
const key = current.timedOut ? 'voiceTimeout'
|
||||||
|
: ['NotAllowedError','PermissionDeniedError'].includes(error.name) ? 'voicePermission'
|
||||||
|
: ['NotFoundError','DevicesNotFoundError'].includes(error.name) ? 'voiceNoMic' : 'voiceFailed';
|
||||||
|
cancel(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function move(id, y){
|
||||||
|
if(!session || !session.holding || session.id !== id) return;
|
||||||
|
session.cancelled = session.startY - y > 72;
|
||||||
|
panel.classList.toggle('is-cancelling', session.cancelled);
|
||||||
|
if(session.phase === 'recording') label.textContent = text(session.cancelled ? 'voiceCancelRelease' : 'voiceCancelTip');
|
||||||
|
}
|
||||||
|
toggle.onclick = function(){
|
||||||
|
if(!mode && !supported()) return;
|
||||||
|
setMode(!mode);
|
||||||
|
if(!mode) input.focus({preventScroll:true});
|
||||||
|
};
|
||||||
|
if(window.PointerEvent){
|
||||||
|
hold.addEventListener('pointerdown', function(event){
|
||||||
|
if(event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){}
|
||||||
|
start(event.pointerId, event.clientY);
|
||||||
|
});
|
||||||
|
hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY));
|
||||||
|
hold.addEventListener('pointerup', function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
if(session && session.id === event.pointerId) finish(session, false);
|
||||||
|
});
|
||||||
|
['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){
|
||||||
|
if(session && session.holding) finish(session, true);
|
||||||
|
}));
|
||||||
|
}else{
|
||||||
|
hold.addEventListener('touchstart', function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
if(event.touches.length !== 1) return;
|
||||||
|
const touch=event.touches[0]; start(touch.identifier, touch.clientY);
|
||||||
|
}, {passive:false});
|
||||||
|
hold.addEventListener('touchmove', function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
for(const touch of event.changedTouches) move(touch.identifier, touch.clientY);
|
||||||
|
}, {passive:false});
|
||||||
|
hold.addEventListener('touchend', function(event){
|
||||||
|
event.preventDefault();
|
||||||
|
for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false);
|
||||||
|
}, {passive:false});
|
||||||
|
hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); });
|
||||||
|
}
|
||||||
|
hold.addEventListener('contextmenu', event => event.preventDefault());
|
||||||
|
hold.addEventListener('keydown', function(event){
|
||||||
|
if(![' ','Enter'].includes(event.key) || event.repeat) return;
|
||||||
|
event.preventDefault(); start('keyboard', 0);
|
||||||
|
});
|
||||||
|
hold.addEventListener('keyup', function(event){
|
||||||
|
if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){
|
||||||
|
event.preventDefault(); finish(session, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); });
|
||||||
|
document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); });
|
||||||
|
window.addEventListener('pagehide', function(){ cancel(); });
|
||||||
|
render();
|
||||||
|
return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>附件暂时无法访问 · 柏悦物流</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||||
|
<style>
|
||||||
|
body { min-height: 100vh; min-height: 100dvh; display: grid; place-items: center; padding: 24px; }
|
||||||
|
main { width: min(420px, 100%); padding: 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 14px; }
|
||||||
|
p { color: var(--secondary); margin: 10px 0; }
|
||||||
|
.english { font-size: 13px; }
|
||||||
|
a { display: inline-block; margin-top: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<h1>附件暂时无法访问</h1>
|
||||||
|
<p>文件可能已过期、被删除或当前无法访问。请返回会话重新发送,或联系人工客服。</p>
|
||||||
|
<p lang="en" class="english">This attachment is unavailable. Please return to your conversation to upload it again or contact support.</p>
|
||||||
|
<a href="{{ url_for('admin.admin_dash') if staff else url_for('pages.index') }}">{{ '返回客服工作台' if staff else '返回聊天' }}</a>
|
||||||
|
</main></body>
|
||||||
|
</html>
|
||||||
+40
-9
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<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, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<meta name="theme-color" content="#315fd5">
|
<meta name="theme-color" content="#315fd5">
|
||||||
<title>柏悦智能物流客服系统</title>
|
<title>柏悦智能物流客服系统</title>
|
||||||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="history-wrap" id="historyWrap"></div>
|
<div class="history-wrap" id="historyWrap"></div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<button type="button" id="customer-account" class="customer-account">登录</button>
|
||||||
<button type="button" class="clear-all-btn">清空全部历史</button>
|
<button type="button" class="clear-all-btn">清空全部历史</button>
|
||||||
<div class="sidebar-note"><span class="status-dot" aria-hidden="true"></span><span data-i18n="serviceScope">中欧与英国专线咨询</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
|
<button class="sidebar-backdrop" type="button" aria-label="关闭历史侧栏"></button>
|
||||||
@@ -44,33 +44,64 @@
|
|||||||
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
|
<div class="ai-avatar"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 7 8-4 8 4v10l-8 4-8-4Z"/><path d="m4 7 8 4 8-4M12 11v10M8 5l8 4"/></svg></div>
|
||||||
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
|
<div class="welcome-eyebrow">中欧 · 英国物流服务</div>
|
||||||
<h3>物流问题,随时为您解答</h3>
|
<h3>物流问题,随时为您解答</h3>
|
||||||
<p>查询物流轨迹、了解专线报价,或联系人工客服。<br>从这里开始您的物流咨询。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="composer">
|
<footer class="composer">
|
||||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||||
<div class="hot-bar">
|
<div class="composer-toolbar"><div class="hot-bar">
|
||||||
<span class="hot-bar-label" data-i18n="quickLabel">快捷咨询</span>
|
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
|
||||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
|
||||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||||
</div>
|
</div><button id="voice-toggle" class="voice-toggle" type="button" aria-label="语音输入" aria-pressed="false" title="语音输入">🎙</button></div>
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||||
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
|
||||||
|
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||||
<button type="button" id="send-button">发送</button>
|
<button type="button" id="send-button">发送</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
<p id="composer-status" class="composer-status" role="status" hidden></p>
|
||||||
<div class="composer-footer"><span class="disclaimer" data-i18n="disclaimer">AI 回答仅供参考,具体以人工客服回复为准</span><span class="keyboard-hint" data-i18n="keyboardHint">Enter 发送 · Shift + Enter 换行</span></div>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
<div id="voice-panel" class="voice-panel" role="status" aria-live="polite" hidden><span class="voice-wave" aria-hidden="true">▂ ▆ █ ▆ ▂</span><p id="voice-label"></p><span id="voice-clock"></span></div>
|
||||||
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
<button type="button" id="to-bottom" class="to-bottom" title="回到底部" aria-label="回到底部">↓</button>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="customer-login" class="customer-login-overlay" hidden>
|
||||||
|
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="customer-login-title">
|
||||||
|
<h2 id="customer-login-title">登录</h2>
|
||||||
|
<form id="customer-login-form">
|
||||||
|
<label for="customer-username" id="customer-username-label">账号</label>
|
||||||
|
<input id="customer-username" name="username" type="text" autocomplete="username" placeholder="请输入账号" maxlength="80" required>
|
||||||
|
<label for="customer-password" id="customer-password-label">密码</label>
|
||||||
|
<input id="customer-password" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" maxlength="256" required>
|
||||||
|
<p id="customer-error" role="alert" hidden></p>
|
||||||
|
<div class="customer-login-actions"><button type="button" id="customer-cancel">取消</button><button type="submit" id="customer-submit">登录</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div id="order-lookup" class="customer-login-overlay" hidden>
|
||||||
|
<section class="customer-login-dialog" role="dialog" aria-modal="true" aria-labelledby="order-lookup-title">
|
||||||
|
<h2 id="order-lookup-title">查入仓码</h2>
|
||||||
|
<form id="order-lookup-form">
|
||||||
|
<label id="order-reference-label" for="order-reference">入仓码</label>
|
||||||
|
<div class="order-reference-field">
|
||||||
|
<input id="order-reference" name="reference" type="text" placeholder="请输入入仓码" maxlength="64" autocomplete="off" autocorrect="off" autocapitalize="characters" spellcheck="false" required>
|
||||||
|
<button id="order-reference-copy" type="button">复制</button>
|
||||||
|
</div>
|
||||||
|
<p id="order-lookup-error" role="alert" hidden></p>
|
||||||
|
<div class="customer-login-actions"><button id="order-lookup-cancel" type="button">取消</button><button id="order-lookup-submit" type="submit">查询轨迹</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function workspace(compact = false){
|
|||||||
setAttribute(name, value){ this.attributes[name] = value; }
|
setAttribute(name, value){ this.attributes[name] = value; }
|
||||||
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
toggleAttribute(name, enabled){ if(enabled) this.attributes[name] = ''; else delete this.attributes[name]; }
|
||||||
appendChild(element){ this.children.push(element); }
|
appendChild(element){ this.children.push(element); }
|
||||||
|
contains(target){ return this === target || this.children.some(child=>child.contains(target)); }
|
||||||
addEventListener(name, callback){ this.listeners[name] = callback; }
|
addEventListener(name, callback){ this.listeners[name] = callback; }
|
||||||
querySelectorAll(){ return []; }
|
querySelectorAll(){ return []; }
|
||||||
querySelector(selector){
|
querySelector(selector){
|
||||||
@@ -47,7 +48,7 @@ async function workspace(compact = false){
|
|||||||
const quickButtons = [new Element(), new Element()];
|
const quickButtons = [new Element(), new Element()];
|
||||||
document = {
|
document = {
|
||||||
body: new Element(), activeElement: null,
|
body: new Element(), activeElement: null,
|
||||||
getElementById(id){ if(!elements.has(id)) elements.set(id, new Element()); return elements.get(id); },
|
getElementById(id){ if(!elements.has(id)){ const value=new Element(); if(id==='agent-emoji-panel') value.hidden=true; elements.set(id,value); } return elements.get(id); },
|
||||||
createElement(){ return new Element(); },
|
createElement(){ return new Element(); },
|
||||||
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
querySelectorAll(selector){ return selector === '.quick' ? quickButtons : []; },
|
||||||
querySelector(selector){
|
querySelector(selector){
|
||||||
@@ -55,7 +56,7 @@ async function workspace(compact = false){
|
|||||||
if(!match) return null;
|
if(!match) return null;
|
||||||
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
return document.getElementById('chatBox').children.find(row => row.attributes['data-message-id'] === match[1]) || null;
|
||||||
},
|
},
|
||||||
addEventListener(name, callback){ documentListeners[name] = callback; },
|
addEventListener(name, callback){ const previous=documentListeners[name]; documentListeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||||
};
|
};
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const hooks = {};
|
const hooks = {};
|
||||||
|
|||||||
+1092
-38
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
const test=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const path=require('node:path');
|
||||||
|
const context=vm.createContext({});
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/drafts.js'),'utf8'),context);
|
||||||
|
function storage(){
|
||||||
|
const data=new Map();
|
||||||
|
return {data,get length(){return data.size;},key:index=>Array.from(data.keys())[index] ?? null,
|
||||||
|
getItem:key=>data.get(key) ?? null,setItem:(key,value)=>data.set(key,value),removeItem:key=>data.delete(key)};
|
||||||
|
}
|
||||||
|
function drafts(scope='1'.repeat(64),local=storage(),tab=storage()){
|
||||||
|
return context.ChatDrafts.create({scope,storage:()=>local,tabStorage:()=>tab});
|
||||||
|
}
|
||||||
|
test('conversation drafts and active tab restore independently of another visitor or tab',()=>{
|
||||||
|
const local=storage(),tab=storage(),a=drafts(undefined,local,tab);
|
||||||
|
a.write(9,'A');a.write(10,'B');a.write(null,'NEW');a.active(9);
|
||||||
|
const restored=drafts(undefined,local,tab);
|
||||||
|
assert.equal(restored.read(9),'A');assert.equal(restored.read(10),'B');assert.equal(restored.read(null),'NEW');
|
||||||
|
assert.equal(restored.active(),'9');
|
||||||
|
const otherTab=drafts(undefined,local);otherTab.write(10,'B updated');
|
||||||
|
assert.equal(otherTab.active(),'new');assert.equal(restored.read(10),'B updated');assert.equal(restored.read(9),'A');
|
||||||
|
otherTab.remove(10);assert.equal(restored.read(10),'');
|
||||||
|
assert.equal(drafts('2'.repeat(64),local,tab).read(9),'');
|
||||||
|
});
|
||||||
|
test('malformed records and invalid identifiers cannot become restored drafts',()=>{
|
||||||
|
const local=storage(),a=drafts(undefined,local),key='baiyue-draft-v1:'+'1'.repeat(64)+':9';
|
||||||
|
for(const value of ['null','{"version":2,"text":"old"}','{"version":1,"text":45}']){
|
||||||
|
local.setItem(key,value);assert.equal(a.read(9),'');
|
||||||
|
}
|
||||||
|
assert.equal(a.write('../9','invalid'),false);assert.equal(a.read('../9'),'');
|
||||||
|
});
|
||||||
|
test('removing and clearing a visitor never clears another visitor or unrelated preferences',()=>{
|
||||||
|
const local=storage(),a=drafts(undefined,local),b=drafts('2'.repeat(64),local);
|
||||||
|
local.setItem('theme','dark');a.write(9,'A');a.write(10,'B');b.write(9,'PRIVATE');
|
||||||
|
a.remove(9);assert.equal(a.read(9),'');assert.equal(a.read(10),'B');
|
||||||
|
a.clear();assert.equal(a.read(10),'');assert.equal(b.read(9),'PRIVATE');assert.equal(local.getItem('theme'),'dark');
|
||||||
|
});
|
||||||
|
test('blocked storage and quota errors retain the latest in-memory draft without a shared bucket',()=>{
|
||||||
|
const broken={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
|
||||||
|
const a=drafts(undefined,broken,broken);
|
||||||
|
assert.equal(a.write(9,'latest'),false);assert.equal(a.read(9),'latest');a.active(9);assert.equal(a.active(),'9');
|
||||||
|
const local=storage(),b=drafts(undefined,local);b.write(9,'old');
|
||||||
|
local.setItem=()=>{throw Error('quota');};assert.equal(b.write(9,'new'),false);assert.equal(b.read(9),'new');
|
||||||
|
const anonymous=drafts(null,local);anonymous.write(9,'memory only');assert.equal(anonymous.read(9),'memory only');
|
||||||
|
assert.equal(drafts(null,local).read(9),'');
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const test=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const fs=require('node:fs');
|
||||||
|
const path=require('node:path');
|
||||||
|
const vm=require('node:vm');
|
||||||
|
const {webcrypto}=require('node:crypto');
|
||||||
|
const flush=()=>new Promise(setImmediate);
|
||||||
|
|
||||||
|
function sessionPage(options={}){
|
||||||
|
const storage=options.storage || new Map(),calls=[],timers=new Map();let timerId=0;
|
||||||
|
const response=(data,status=200)=>({ok:status>=200&&status<300,status,json:async()=>data});
|
||||||
|
const context=vm.createContext({console,AbortController,Uint8Array,
|
||||||
|
window:{crypto:webcrypto},
|
||||||
|
localStorage:{getItem:key=>{if(options.storageBlocked) throw Error();return storage.get(key);},
|
||||||
|
setItem:(key,value)=>{if(options.storageBlocked) throw Error();storage.set(key,value);}},
|
||||||
|
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},clearTimeout:id=>timers.delete(id),
|
||||||
|
fetch:async(url,init)=>{calls.push({url,init});
|
||||||
|
if(options.fetch) return options.fetch(url,init,response);
|
||||||
|
return url==='/api/visitor/session' ? response({ok:true,key:JSON.parse(init.body).key || 'f'.repeat(64)}) : response([]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/visitor.js'),'utf8'),context);
|
||||||
|
return {fetch:context.VisitorSession.fetch,calls,storage,
|
||||||
|
expire:async()=>{for(const timer of [...timers.values()])timer.callback();await flush();}};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a local random key is restored across reloads and differs in another browser',async()=>{
|
||||||
|
const a=sessionPage();await a.fetch('/api/get_history');
|
||||||
|
const key=JSON.parse(a.calls[0].init.body).key;
|
||||||
|
assert.match(key,/^[0-9a-f]{64}$/);
|
||||||
|
const restored=sessionPage({storage:a.storage});await restored.fetch('/api/get_history');
|
||||||
|
assert.equal(JSON.parse(restored.calls[0].init.body).key,key);
|
||||||
|
const b=sessionPage();await b.fetch('/api/get_history');
|
||||||
|
assert.notEqual(JSON.parse(b.calls[0].init.body).key,key);
|
||||||
|
assert.equal(a.calls[1].init.credentials,'same-origin');
|
||||||
|
assert.equal(a.calls.some(call=>call.url.includes(key)),false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent requests wait for one bootstrap before sending customer data',async()=>{
|
||||||
|
let release;
|
||||||
|
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
|
||||||
|
? new Promise(resolve=>{release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));}) : response([])});
|
||||||
|
const a=app.fetch('/api/get_history'),b=app.fetch('/api/new_chat',{method:'POST'});
|
||||||
|
assert.equal(app.calls.length,1);
|
||||||
|
release();await Promise.all([a,b]);
|
||||||
|
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,1);
|
||||||
|
assert.equal(app.calls.length,3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocked local storage uses a server cookie without falling back to global history',async()=>{
|
||||||
|
const app=sessionPage({storageBlocked:true});await app.fetch('/api/get_history');
|
||||||
|
assert.equal(JSON.parse(app.calls[0].init.body).key,null);
|
||||||
|
assert.equal(app.calls[1].init.credentials,'same-origin');
|
||||||
|
const failed=sessionPage({fetch:(url,init,response)=>response({ok:false},503)});
|
||||||
|
await assert.rejects(failed.fetch('/api/get_history'));
|
||||||
|
assert.equal(failed.calls.length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired sessions rebootstrap once and retry the original request once',async()=>{
|
||||||
|
let attempts=0;
|
||||||
|
const app=sessionPage({fetch:(url,init,response)=>url==='/api/visitor/session'
|
||||||
|
? response({ok:true,key:JSON.parse(init.body).key}) : response([],++attempts===1 ? 401 : 200)});
|
||||||
|
const result=await app.fetch('/api/get_history');
|
||||||
|
assert.equal(result.status,200);
|
||||||
|
assert.equal(app.calls.filter(call=>call.url==='/api/visitor/session').length,2);
|
||||||
|
assert.equal(attempts,2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cancelled request waiting for bootstrap does not upload its body',async()=>{
|
||||||
|
let release;
|
||||||
|
const app=sessionPage({fetch:(url,init,response)=>new Promise(resolve=>{
|
||||||
|
release=()=>resolve(response({ok:true,key:JSON.parse(init.body).key}));
|
||||||
|
})});
|
||||||
|
const controller=new AbortController();
|
||||||
|
const pending=app.fetch('/api/upload',{method:'POST',body:'private file',signal:controller.signal});
|
||||||
|
controller.abort();release();
|
||||||
|
await assert.rejects(pending,{name:'AbortError'});
|
||||||
|
assert.equal(app.calls.length,1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const flush = () => new Promise(setImmediate);
|
||||||
|
|
||||||
|
function voicePage(options={}){
|
||||||
|
const elements={}, timers=new Map(), docEvents={}, pageEvents={}, calls=[], messages=[], statuses=[];
|
||||||
|
let now=1000, timerId=0, contextId='conversation-1', recorder, permissionCalls=0, trackStops=0;
|
||||||
|
class Element {
|
||||||
|
constructor(){ this.listeners={}; this.attributes={}; this.classes=new Set(); this.value='';
|
||||||
|
this.classList={toggle:(name,on) => on ? this.classes.add(name) : this.classes.delete(name),remove:name=>this.classes.delete(name)}; }
|
||||||
|
setAttribute(name,value){this.attributes[name]=value;}
|
||||||
|
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||||
|
setPointerCapture(){}
|
||||||
|
focus(options){this.focusOptions=options;}
|
||||||
|
blur(){this.blurred=true;}
|
||||||
|
}
|
||||||
|
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||||
|
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||||
|
class Recorder {
|
||||||
|
constructor(source, settings){ recorder=this; this.state='inactive'; this.mimeType=settings.mimeType || 'audio/mp4'; }
|
||||||
|
static isTypeSupported(type){return options.mp4 ? type==='audio/mp4' : type.startsWith('audio/webm');}
|
||||||
|
start(){this.state='recording';}
|
||||||
|
stop(){this.state='inactive'; this.ondataavailable({data:new Blob(['audio fixture'],{type:this.mimeType})}); this.onstop();}
|
||||||
|
}
|
||||||
|
class ClockDate extends Date {static now(){return now;}}
|
||||||
|
const response = data => ({ok:true,json:async()=>data});
|
||||||
|
const sandbox=vm.createContext({console,Blob,AbortController,Date:ClockDate,
|
||||||
|
FormData:class{constructor(){this.values={};}append(key,value){this.values[key]=value;}},
|
||||||
|
navigator:{mediaDevices:{getUserMedia:async()=>{permissionCalls++; if(options.permission) return options.permission(); return stream;}}},
|
||||||
|
document:{hidden:false,addEventListener:(name,callback)=>{docEvents[name]=callback;}},
|
||||||
|
window:{isSecureContext:options.secure!==false,MediaRecorder:options.unsupported ? undefined : Recorder,
|
||||||
|
PointerEvent:options.touch ? undefined : function(){},addEventListener:(name,callback)=>{pageEvents[name]=callback;}},
|
||||||
|
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},
|
||||||
|
clearTimeout:id=>timers.delete(id),setInterval:()=>++timerId,clearInterval(){},
|
||||||
|
fetch:async(url,init)=>{calls.push({url,init});
|
||||||
|
if(url==='/api/speech/config') return response({enabled:options.enabled!==false,max_bytes:5242880,max_seconds:60});
|
||||||
|
if(options.transcribe) return options.transcribe(init);
|
||||||
|
return response({ok:true,text:'查询订单 FL20261030001'});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/voice.js'),'utf8'),sandbox);
|
||||||
|
const voice=sandbox.ChatVoice.create({...elements,text:key=>key,status:(...args)=>statuses.push(args),
|
||||||
|
language:()=> 'zh',context:()=>contextId,canStart:()=>options.reason || '',onText:async text=>messages.push(text)});
|
||||||
|
const event=(extra={})=>({button:0,pointerId:7,clientY:300,preventDefault(){},...extra});
|
||||||
|
return {voice,elements,calls,messages,statuses,stream,
|
||||||
|
get permissionCalls(){return permissionCalls;},get trackStops(){return trackStops;},get recorder(){return recorder;},
|
||||||
|
context:value=>{contextId=value;},
|
||||||
|
open:()=>elements.toggle.onclick(),
|
||||||
|
down:async()=>{elements.hold.listeners.pointerdown(event());await flush();},
|
||||||
|
up:async(duration=1000)=>{now+=duration;elements.hold.listeners.pointerup(event());await flush();},
|
||||||
|
move:y=>elements.hold.listeners.pointermove(event({clientY:y})),
|
||||||
|
event,docEvents,pageEvents,sandbox,
|
||||||
|
expire:async delay=>{for(const [id,timer] of [...timers]) if(timer.delay===delay){timers.delete(id);now+=delay;timer.callback();}await flush();},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hold and release transcribes audio and sends text once',async()=>{
|
||||||
|
const app=voicePage();app.open();await app.down();
|
||||||
|
assert.equal(app.recorder.state,'recording');
|
||||||
|
assert.equal(app.elements.panel.hidden,false);
|
||||||
|
await app.up();
|
||||||
|
assert.deepEqual(app.messages,['查询订单 FL20261030001']);
|
||||||
|
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,1);
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
assert.equal(app.elements.panel.hidden,true);
|
||||||
|
await app.up();
|
||||||
|
assert.equal(app.messages.length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
||||||
|
const app=voicePage();
|
||||||
|
app.open();
|
||||||
|
assert.ok(app.elements.input.blurred);
|
||||||
|
app.open();
|
||||||
|
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
||||||
|
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||||
|
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,0);
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
await app.down();await app.up();
|
||||||
|
assert.equal(app.messages.length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short presses and unavailable service do not upload recordings',async()=>{
|
||||||
|
const app=voicePage();app.open();await app.down();await app.up(50);
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
assert.equal(app.statuses.at(-1)[0],'voiceTooShort');
|
||||||
|
const disabled=voicePage({enabled:false});disabled.open();await disabled.down();
|
||||||
|
assert.equal(disabled.permissionCalls,0);
|
||||||
|
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
|
||||||
|
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
||||||
|
app.open();await app.down();await app.up();grant(app.stream);await flush();
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
assert.equal(app.voice.busy(),false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching conversation or explicitly cancelling ignores a late transcript',async()=>{
|
||||||
|
for(const cancelled of [false,true]){
|
||||||
|
let resolve;const app=voicePage({transcribe:()=>new Promise(done=>{resolve=done;})});
|
||||||
|
app.open();await app.down();await app.up();
|
||||||
|
if(cancelled) app.voice.cancel(); else app.context('conversation-2');
|
||||||
|
resolve({ok:true,json:async()=>({ok:true,text:'must not enter the new conversation'})});await flush();
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
assert.equal(app.voice.busy(),false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permission denial and unsupported or insecure browsers leave typing available',async()=>{
|
||||||
|
const denied=voicePage({permission:()=>Promise.reject(Object.assign(new Error(),{name:'NotAllowedError'}))});
|
||||||
|
denied.open();await denied.down();
|
||||||
|
assert.equal(denied.voice.busy(),false);
|
||||||
|
assert.equal(denied.statuses.at(-1)[0],'voicePermission');
|
||||||
|
for(const options of [{unsupported:true},{secure:false}]){
|
||||||
|
const app=voicePage(options);app.open();
|
||||||
|
assert.equal(app.elements.inputArea.classes.has('voice-mode'),false);
|
||||||
|
assert.equal(app.permissionCalls,0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending drafts and busy chat do not request microphone access',async()=>{
|
||||||
|
const app=voicePage({reason:'wait for current chat'});app.open();await app.down();
|
||||||
|
assert.equal(app.permissionCalls,0);
|
||||||
|
assert.equal(app.calls.length,0);
|
||||||
|
assert.equal(app.statuses.at(-1)[0],'wait for current chat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maximum recording length stops the microphone and sends once',async()=>{
|
||||||
|
const app=voicePage({mp4:true});app.open();await app.down();await app.expire(60000);
|
||||||
|
assert.equal(app.messages.length,1);
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
assert.equal(app.calls.find(call=>call.url==='/api/speech/transcribe').init.body.values.audio.type,'audio/mp4');
|
||||||
|
await app.up();assert.equal(app.messages.length,1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('touch fallback and browser cancellation do not submit accidentally',async()=>{
|
||||||
|
const app=voicePage({touch:true});app.open();
|
||||||
|
app.elements.hold.listeners.touchstart(app.event({touches:[{identifier:1,clientY:300}]}));await flush();
|
||||||
|
app.elements.hold.listeners.touchcancel();await flush();
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stalled transcription times out and releases the recorder state',async()=>{
|
||||||
|
const app=voicePage({transcribe:init=>new Promise((resolve,reject)=>{
|
||||||
|
init.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
|
||||||
|
})});app.open();await app.down();await app.up();await app.expire(40000);
|
||||||
|
assert.equal(app.voice.busy(),false);
|
||||||
|
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unanswered microphone permission prompt times out and a late stream is closed',async()=>{
|
||||||
|
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
||||||
|
app.open();await app.down();await app.expire(15000);
|
||||||
|
assert.equal(app.voice.busy(),false);
|
||||||
|
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
||||||
|
grant(app.stream);await flush();
|
||||||
|
assert.ok(app.trackStops>0);
|
||||||
|
assert.equal(app.messages.length,0);
|
||||||
|
});
|
||||||
+234
-2
@@ -45,11 +45,26 @@ class ApiTests(unittest.TestCase):
|
|||||||
self.root = Path(self.directory.name)
|
self.root = Path(self.directory.name)
|
||||||
self.app = self.application(self.root)
|
self.app = self.application(self.root)
|
||||||
self.client = self.app.test_client()
|
self.client = self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||||
|
self.app.extensions['rate_hits'].clear()
|
||||||
|
from logistics_chat.order_data import import_records
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
import_records(connection, [{'tenant_id':'fixture', 'order_no':'15252125',
|
||||||
|
'status':'PROCESSING', 'country_code':'US', 'gross_weight_kg':'30',
|
||||||
|
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'}]}])
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
def application(self, root):
|
def application(self, root):
|
||||||
return create_app({'TESTING': True, 'DATABASE': root/'history.db',
|
app = create_app({'TESTING': True, 'DATABASE': root/'history.db',
|
||||||
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
|
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups',
|
||||||
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
|
'LOG_DIR': root/'logs', 'DEEPSEEK_API_KEY': ''})
|
||||||
|
@app.before_request
|
||||||
|
def fixture_customer():
|
||||||
|
from flask import g
|
||||||
|
g.customer_tenant_id = 'fixture'
|
||||||
|
return app
|
||||||
|
|
||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.client.post('/api/new_chat').json['id']
|
return self.client.post('/api/new_chat').json['id']
|
||||||
@@ -114,6 +129,167 @@ class ApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(events[0]['card']['no'], '15252125')
|
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||||
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
|
self.assertEqual(self.client.get(f'/api/get_messages?conv_id={cid}').json[1]['content'], '查询15252125 ' + attachment)
|
||||||
|
|
||||||
|
def test_image_attachments_do_not_swallow_order_questions_and_are_saved(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
image = '[图片]/uploads/20261029100145_6787.png'
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg':'查询15252125 '+image, 'conv_id':cid}))
|
||||||
|
self.assertEqual(events[0]['card']['no'], '15252125')
|
||||||
|
events = frames(self.client.post('/stream_chat', json={'msg':image, 'conv_id':cid}))
|
||||||
|
self.assertIn('收到您的图片', events[0]['t'])
|
||||||
|
messages = self.client.get(f'/api/get_messages?conv_id={cid}').json
|
||||||
|
self.assertEqual(messages[-2]['content'], image)
|
||||||
|
self.assertIn('收到您的图片', messages[-1]['content'])
|
||||||
|
|
||||||
|
def test_bad_chat_payloads_produce_readable_errors_without_calling_model(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
invalid = ([1], 'hello', {'msg':7}, {'msg':['test']}, {'msg':'物流咨询', 'conv_id':{}},
|
||||||
|
{'msg':'物流咨询', 'conv_id':-1}, {'msg':'物流咨询', 'conv_id':1.5}, {'msg':'物流咨询','conv_id':False},
|
||||||
|
{'msg':'物流咨询','conv_id':999999999999999999999999},
|
||||||
|
{'msg':'物流咨询','conv_id':'²'}, {'msg':'物流咨询','conv_id':'9'*5000})
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for index, payload in enumerate(invalid):
|
||||||
|
with self.subTest(case=index):
|
||||||
|
events = frames(self.client.post('/stream_chat', json=payload))
|
||||||
|
self.assertIn('e', events[0])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_deleted_conversations_close_human_sessions_and_reject_new_chat_messages(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
from datetime import datetime
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
sid = self.client.post('/api/human/request',json={'conv_id':cid}).json['session_id']
|
||||||
|
self.client.post('/api/human/send',json={'sid':sid,'content':'原会话内容'})
|
||||||
|
self.client.delete(f'/api/del_history?id={cid}')
|
||||||
|
self.assertEqual(self.client.get('/api/human/queue').json, [])
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['messages'], [])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'15252125','conv_id':cid}))
|
||||||
|
self.assertIn('已删除',events[0]['e'])
|
||||||
|
model.assert_not_called()
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
self.assertEqual(self.client.post('/api/human/request',json={'conv_id':cid}).status_code,404)
|
||||||
|
|
||||||
|
def test_uk_quotes_and_followups_keep_the_uk_rates(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
cid = self.conversation()
|
||||||
|
for message, expected in (('30kg英国空运报价','英国空运专线'), ('那海运呢?','英国海运'),
|
||||||
|
('那空运呢?','英国空运专线'), ('那50公斤呢?','英国空运专线')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
prompt = model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(expected,prompt)
|
||||||
|
self.assertNotIn('欧洲空运专线',prompt)
|
||||||
|
self.assertNotIn('欧洲海运快线',prompt)
|
||||||
|
|
||||||
|
def test_us_quotes_never_reuse_european_price_tables(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'30KG发美国FBA仓,海运报价','conv_id':self.conversation()}))
|
||||||
|
self.assertIn('目的地',events[0]['t'])
|
||||||
|
self.assertIn('人工',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_mixed_country_codes_select_the_correct_rates(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
for message, channel, amount in (('30kg发UK海运报价','英国海运','384'),
|
||||||
|
('30kg发uk海运报价','英国海运','384'),
|
||||||
|
('30kg发GB海运报价','英国海运','384'),
|
||||||
|
('30kg发DE仓海运报价','欧洲海运','369')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion', return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message}))
|
||||||
|
self.assertEqual(events[-1],{'d':1})
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(channel,prompt)
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
for message in ('30kg发US仓海运报价','30kg发us仓海运报价','30kg发USFBA仓海运报价',
|
||||||
|
'30kg发阿根廷海运报价','30kg海运报价'):
|
||||||
|
with self.subTest(message=message), patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message}))
|
||||||
|
self.assertIn('目的',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_short_channel_followups_and_retries_keep_weight_and_region(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
sequence=(('30kg英国海运报价','英国海运','384'), ('海运呢','英国海运','384'),
|
||||||
|
('空运呢','英国空运','1,440'), ('50公斤呢','英国空运','2,400'))
|
||||||
|
for message, channel, amount in sequence:
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(channel,prompt)
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',side_effect=requests.exceptions.ConnectionError):
|
||||||
|
self.assertIn('e',frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))[0])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model:
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':'海运呢','conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn('英国海运',prompt)
|
||||||
|
self.assertIn('640',prompt)
|
||||||
|
|
||||||
|
def test_complete_new_questions_do_not_inherit_an_old_quote(self):
|
||||||
|
from logistics_chat.chat_service import shipping_intent_with_context
|
||||||
|
cid=self.conversation()
|
||||||
|
with self.app.app_context():
|
||||||
|
db.save_message(cid,'user','30kg英国海运报价')
|
||||||
|
db.save_message(cid,'assistant','报价结果')
|
||||||
|
for message in ('新的一票50kg德国空运报价','空运理赔应该怎么办?','海运时效一般多久?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
intent=shipping_intent_with_context(message,cid)
|
||||||
|
self.assertNotEqual(intent['region'],'uk')
|
||||||
|
db.save_message(cid,'user','空运理赔应该怎么办?')
|
||||||
|
self.assertIsNone(shipping_intent_with_context('海运呢',cid)['weight'])
|
||||||
|
|
||||||
|
def test_dimension_quotes_and_changes_do_not_become_order_identifiers(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
for message, amount in (('30kg英国海运,100x100x100cm','1,817'), ('50x40x30cm呢','384'),
|
||||||
|
('1.2x0.8x0.6m呢','1,229'),
|
||||||
|
('那另外一票50kg德国海运','615')):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':message,'conv_id':cid}))
|
||||||
|
self.assertEqual(events[-1],{'d':1})
|
||||||
|
self.assertTrue(model.called)
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn(amount,prompt)
|
||||||
|
if '另外一票' in message:
|
||||||
|
self.assertNotIn('体积重',prompt)
|
||||||
|
|
||||||
|
def test_destination_only_replies_continue_the_existing_quote(self):
|
||||||
|
self.app.config['DEEPSEEK_API_KEY']='test-model-key'
|
||||||
|
cid=self.conversation()
|
||||||
|
self.assertIn('目的',frames(self.client.post('/stream_chat',json={'msg':'30kg海运报价','conv_id':cid}))[0]['t'])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion',return_value=ModelResponse()) as model, patch('logistics_chat.chat_service.gen_followups',return_value=[]):
|
||||||
|
frames(self.client.post('/stream_chat',json={'msg':'英国','conv_id':cid}))
|
||||||
|
prompt=model.call_args.args[0]['messages'][0]['content']
|
||||||
|
self.assertIn('英国海运',prompt)
|
||||||
|
self.assertIn('384',prompt)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':'US仓','conv_id':cid}))
|
||||||
|
self.assertIn('尚未包含',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_uk_volume_quotes_do_not_reuse_continental_warehouse_prices(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'2方发英国海运多少钱'}))
|
||||||
|
self.assertIn('未提供',events[0]['t'])
|
||||||
|
self.assertIn('按方费率',events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_closed_human_sessions_cannot_be_reopened_by_a_late_accept(self):
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
from datetime import datetime
|
||||||
|
clock.now.return_value = datetime(2026,10,29,12)
|
||||||
|
sid = self.client.post('/api/human/request',json={'conv_id':self.conversation()}).json['session_id']
|
||||||
|
self.client.post('/api/human/close',json={'sid':sid})
|
||||||
|
self.assertEqual(self.client.post('/api/human/accept',json={'sid':sid}).status_code,409)
|
||||||
|
self.assertEqual(self.client.get(f'/api/human/poll?sid={sid}').json['status'],'closed')
|
||||||
|
|
||||||
def test_human_lifecycle_typing_and_read_receipts(self):
|
def test_human_lifecycle_typing_and_read_receipts(self):
|
||||||
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
|
self.assertEqual(self.client.post('/api/human/request', json={'conv_id': 0}).status_code, 400)
|
||||||
with patch('logistics_chat.human_service.datetime') as clock:
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
@@ -202,6 +378,42 @@ class ApiTests(unittest.TestCase):
|
|||||||
self.client.delete('/api/clear_all')
|
self.client.delete('/api/clear_all')
|
||||||
self.assertEqual(self.client.get('/api/get_history').json, [])
|
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||||
|
|
||||||
|
def test_browser_votes_and_legacy_rows_are_both_counted_in_admin_stats(self):
|
||||||
|
for vote in ('up', 'down', 1, -1):
|
||||||
|
self.assertEqual(self.client.post('/api/feedback', json={'vote':vote,'question':'q','answer':'a'}).status_code,200)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
connection.executemany('INSERT INTO feedback(vote) VALUES(?)', [('up',),('down',)])
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
stats=self.client.get('/api/admin/stats').json
|
||||||
|
self.assertEqual((stats['good'],stats['bad']),(3,3))
|
||||||
|
|
||||||
|
def test_invalid_customer_feedback_and_tickets_return_json_errors_without_writes(self):
|
||||||
|
for data in (None, [], [1], {}, {'vote':True}, {'vote':0}, {'vote':'unknown'},
|
||||||
|
{'vote':'up','answer':{}}, {'vote':'down','question':[]}):
|
||||||
|
with self.subTest(feedback=data):
|
||||||
|
response=self.client.post('/api/feedback',json=data)
|
||||||
|
self.assertEqual(response.status_code,400)
|
||||||
|
self.assertFalse(response.json['ok'])
|
||||||
|
for data in (None, [], [1], {}, {'content':' '}, {'content':{}}, {'content':'x'*2001},
|
||||||
|
{'content':'help','phone':[]}, {'content':'help','phone':'1'*33}):
|
||||||
|
with self.subTest(ticket=data):
|
||||||
|
response=self.client.post('/api/ticket',json=data)
|
||||||
|
self.assertEqual(response.status_code,400)
|
||||||
|
self.assertFalse(response.json['ok'])
|
||||||
|
for path in ('/api/feedback','/api/ticket'):
|
||||||
|
response=self.client.post(path,data='{bad',content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code,400)
|
||||||
|
self.assertFalse(response.json['ok'])
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
for table in ('feedback','tickets'):
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM '+table).fetchone()[0],0)
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(self.client.post('/api/ticket',json={'content':' 请跟进报价 ','phone':' +86 123 '}).status_code,200)
|
||||||
|
ticket=self.client.get('/api/tickets').json[0]
|
||||||
|
self.assertEqual((ticket['content'],ticket['phone']),('请跟进报价','+86 123'))
|
||||||
|
|
||||||
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
def test_business_routes_with_initialized_tables_and_legacy_auth(self):
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
connection = db.connect()
|
connection = db.connect()
|
||||||
@@ -223,13 +435,33 @@ class ApiTests(unittest.TestCase):
|
|||||||
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
|
streamed = ModelResponse('这是用于覆盖长回答压缩的说明。'*100)
|
||||||
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
|
responses = [streamed, ModelResponse('基础运费2050元,时效38-45天。'), ModelResponse('["费用呢","时效呢","能带电吗"]')]
|
||||||
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
|
with patch('logistics_chat.ai_client.requests.post', side_effect=responses) as post:
|
||||||
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 海运', 'conv_id': self.conversation()}))
|
events = frames(self.client.post('/stream_chat', json={'msg': '200kg 德国海运', 'conv_id': self.conversation()}))
|
||||||
self.assertTrue(any('t' in event for event in events))
|
self.assertTrue(any('t' in event for event in events))
|
||||||
self.assertTrue(any('replace' 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.assertTrue(any('suggest' in event for event in events))
|
||||||
self.assertEqual(events[-1], {'d': 1})
|
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.assertEqual([call.kwargs['timeout'] for call in post.call_args_list], [(5, 90), (5, 30), (5, 15)])
|
||||||
self.assertTrue(streamed.closed)
|
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):
|
def test_stream_network_and_model_errors(self):
|
||||||
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
self.app.config['DEEPSEEK_API_KEY'] = 'test-model-key'
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ class ConfigurationTests(unittest.TestCase):
|
|||||||
self.assertIs(response, sentinel.response)
|
self.assertIs(response, sentinel.response)
|
||||||
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""Customer login, tenant scope and sensitive-history lifecycle."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db, order_data
|
||||||
|
from logistics_chat.customer_service import COOKIE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||||
|
root=Path(folder.name)
|
||||||
|
self.app=create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||||
|
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||||
|
'ORDER_DATA_TENANT_ID':'','CUSTOMER_DEMO_TENANT_ID':'tenant-a','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.a,self.b=self.app.test_client(),self.app.test_client()
|
||||||
|
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||||
|
client.post('/api/visitor/session',json={'key':key})
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
order_data.import_records(connection,[{'order_no':'ORDER20260001','tenant_id':'tenant-a','gross_weight_kg':30},
|
||||||
|
{'order_no':'ORDER20260002','tenant_id':'tenant-b','gross_weight_kg':99}])
|
||||||
|
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
|
||||||
|
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
|
||||||
|
connection.commit();connection.close()
|
||||||
|
|
||||||
|
def login(self, client=None, username='admin'):
|
||||||
|
return (client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'})
|
||||||
|
|
||||||
|
def events(self,message,conv=None,client=None):
|
||||||
|
response=(client or self.a).post('/stream_chat',json={'msg':message,'conv_id':conv})
|
||||||
|
return [json.loads(part[6:]) for part in response.get_data(as_text=True).split('\n\n') if part.startswith('data: ')]
|
||||||
|
|
||||||
|
def test_guest_must_login_then_customer_reads_only_the_linked_scope(self):
|
||||||
|
with patch('logistics_chat.order_data.lookup_order') as lookup:
|
||||||
|
self.assertTrue(self.events('查询ORDER20260001')[0]['login_required']);lookup.assert_not_called()
|
||||||
|
self.assertTrue(self.login().json['ok'])
|
||||||
|
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.assertEqual(self.events('查询ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||||
|
self.assertIn('未找到',self.events('查询ORDER20260002')[0]['t'])
|
||||||
|
forged=self.a.post('/stream_chat',json={'msg':'ORDER20260002','tenant_id':'tenant-b'},headers={'X-Tenant-Id':'tenant-b'})
|
||||||
|
self.assertNotIn('99 kg',forged.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_common_tracking_requests_require_login_without_calling_the_model(self):
|
||||||
|
questions = ('查物流', '帮我查一下物流', '物流查询', '查货', '帮我查下轨迹',
|
||||||
|
'我的货到哪了', '我的包裹到哪里了?', '这票货什么时候到?',
|
||||||
|
'Where is my parcel?', 'Track my package', 'What is my shipment status?')
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for question in questions:
|
||||||
|
with self.subTest(question=question):
|
||||||
|
self.assertTrue(self.events(question)[0].get('login_required'))
|
||||||
|
self.login()
|
||||||
|
for question in questions:
|
||||||
|
with self.subTest(authenticated_question=question):
|
||||||
|
self.assertIn('请提供入仓码', self.events(question)[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_credentials_are_hashed_and_session_is_opaque_secure_expiring_and_revoked(self):
|
||||||
|
secure=self.app.test_client()
|
||||||
|
secure.post('/api/visitor/session',json={'key':'3'*64},base_url='https://localhost')
|
||||||
|
response=secure.post('/api/customer/login',json={'username':'admin','password':'123456'},base_url='https://localhost')
|
||||||
|
for flag in ('Secure','HttpOnly','SameSite=Lax'):
|
||||||
|
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||||
|
self.login();token=self.a.get_cookie(COOKIE_NAME).value
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
self.assertNotEqual(connection.execute("SELECT password_hash FROM customer_users WHERE username='admin'").fetchone()[0],'123456')
|
||||||
|
self.assertTrue(connection.execute('SELECT 1 FROM customer_sessions WHERE token_hash=?',
|
||||||
|
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone());connection.close()
|
||||||
|
with patch('logistics_chat.customer_service.time.time',return_value=time.time()+9*3600):
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.a.post('/api/customer/logout');self.a.set_cookie(COOKIE_NAME,token)
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
|
||||||
|
def test_customer_cookie_cannot_be_reused_by_another_guest_and_bad_logins_are_limited(self):
|
||||||
|
self.login();self.b.set_cookie(COOKIE_NAME,self.a.get_cookie(COOKIE_NAME).value)
|
||||||
|
self.assertFalse(self.b.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.b.post('/api/customer/logout')
|
||||||
|
self.assertTrue(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
for index in range(5):
|
||||||
|
response=self.b.post('/api/customer/login',json={'username':'admin','password':'wrong'})
|
||||||
|
self.assertEqual(response.status_code,403)
|
||||||
|
self.assertEqual(self.login(self.b).status_code,429)
|
||||||
|
|
||||||
|
def test_order_history_is_hidden_after_logout_and_restored_only_to_its_customer_and_guest(self):
|
||||||
|
self.login();cid=self.a.post('/api/new_chat').json['id']
|
||||||
|
self.events('ORDER20260001',cid)
|
||||||
|
self.a.post('/api/customer/logout')
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||||
|
self.assertTrue(self.events('继续查询',cid)[0]['login_required'])
|
||||||
|
self.a.delete('/api/clear_all')
|
||||||
|
self.login(username='other')
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.login()
|
||||||
|
self.assertTrue(any('__CARD__' in row['content'] for row in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||||
|
self.login(self.b)
|
||||||
|
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
|
||||||
|
def test_cross_site_login_logout_and_oversized_or_malformed_requests_fail(self):
|
||||||
|
for path in ('login','logout'):
|
||||||
|
self.assertEqual(self.a.post('/api/customer/'+path,json={'username':'admin','password':'123456'},
|
||||||
|
headers={'Origin':'https://foreign.example'}).status_code,403)
|
||||||
|
self.assertEqual(self.a.post('/api/customer/login',json=[]).status_code,400)
|
||||||
|
self.assertEqual(self.a.post('/api/customer/login',json={'username':[],'password':'123456'}).status_code,400)
|
||||||
|
self.assertEqual(self.a.post('/api/customer/login',json={'padding':'x'*3000}).status_code,413)
|
||||||
|
|
||||||
|
def test_disabled_accounts_and_password_changes_invalidate_existing_sessions(self):
|
||||||
|
self.login()
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.assertEqual(self.login().status_code,403)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET enabled=1 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.login()
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET password_hash=password_hash||'changed' WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
|
||||||
|
def test_demo_binding_does_not_pick_an_arbitrary_tenant_when_dataset_has_multiple(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||||
|
self.app.config['CUSTOMER_DEMO_LOCAL_ORDERS']=False
|
||||||
|
self.assertTrue(self.login().json['ok'])
|
||||||
|
reply=self.events('ORDER20260001')[0]
|
||||||
|
self.assertIn('尚未关联',reply['t']);self.assertNotIn('login_required',reply)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||||
|
self.login()
|
||||||
|
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||||
|
|
||||||
|
def test_tenant_reassignment_does_not_reveal_the_accounts_old_order_history(self):
|
||||||
|
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260001',cid)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='tenant-b' WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.login()
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||||
|
|
||||||
|
def test_demo_disable_revokes_default_login_without_disabling_real_customer_records(self):
|
||||||
|
self.login();self.app.config['CUSTOMER_DEMO_ENABLED']=False
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.assertEqual(self.login().status_code,403)
|
||||||
|
self.assertTrue(self.login(username='other').json['authenticated'])
|
||||||
|
|
||||||
|
def test_parallel_invalid_logins_cannot_bypass_the_attempt_budget(self):
|
||||||
|
barrier=Barrier(6)
|
||||||
|
def attempt(index):
|
||||||
|
with self.app.test_client() as client:
|
||||||
|
client.set_cookie('baiyue_visitor','1'*64)
|
||||||
|
barrier.wait(timeout=5)
|
||||||
|
return client.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code
|
||||||
|
with ThreadPoolExecutor(max_workers=6) as executor:
|
||||||
|
statuses=list(executor.map(attempt,range(6)))
|
||||||
|
self.assertEqual(statuses.count(403),5);self.assertEqual(statuses.count(429),1)
|
||||||
|
|
||||||
|
def test_default_local_account_has_explicit_existing_tenants_and_real_users_remain_scoped(self):
|
||||||
|
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.assertTrue(self.login().json['authenticated'])
|
||||||
|
self.assertEqual(self.events('ORDER20260001')[0]['card']['no'],'ORDER20260001')
|
||||||
|
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
order_data.import_records(connection,[{'order_no':'ORDER20260003','tenant_id':'new-ungranted-tenant'}]);connection.commit();connection.close()
|
||||||
|
self.assertIn('未找到',self.events('ORDER20260003')[0]['t'])
|
||||||
|
self.login(username='other')
|
||||||
|
self.assertIn('未找到',self.events('ORDER20260001')[0]['t'])
|
||||||
|
self.assertEqual(self.events('ORDER20260002')[0]['card']['no'],'ORDER20260002')
|
||||||
|
|
||||||
|
def test_removing_a_tenant_grant_revokes_sessions_and_old_scope_history(self):
|
||||||
|
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='tenant-b'");connection.commit();connection.close()
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.login()
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||||
|
|
||||||
|
def test_revoking_every_local_grant_is_not_undone_by_login_or_startup(self):
|
||||||
|
from logistics_chat.customer_service import init_customer_tables
|
||||||
|
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.login()
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute('DELETE FROM customer_tenant_grants');connection.commit()
|
||||||
|
init_customer_tables(connection);connection.commit();connection.close()
|
||||||
|
self.login()
|
||||||
|
self.assertIn('尚未关联',self.events('ORDER20260001')[0]['t'])
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();self.assertEqual(connection.execute('SELECT COUNT(*) FROM customer_tenant_grants').fetchone()[0],0);connection.close()
|
||||||
|
|
||||||
|
def test_server_scope_narrowing_revokes_the_session_and_hides_broader_history(self):
|
||||||
|
self.app.config['CUSTOMER_DEMO_TENANT_ID']=''
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect();connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=0 WHERE username='admin'");connection.commit();connection.close()
|
||||||
|
self.login();cid=self.a.post('/api/new_chat').json['id'];self.events('ORDER20260002',cid)
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID']='tenant-a'
|
||||||
|
self.assertFalse(self.a.get('/api/customer/session').json['authenticated'])
|
||||||
|
self.login()
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.assertIn('未找到',self.events('ORDER20260002')[0]['t'])
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
"""Exercise the server factory using disposable state/log paths and no model key."""
|
"""Exercise the server factory using disposable state/log paths and no model key."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from deploy import wsgi
|
from deploy import wsgi
|
||||||
|
from logistics_chat import create_app
|
||||||
|
from waitress.adjustments import Adjustments
|
||||||
|
from waitress.proxy_headers import proxy_headers_middleware
|
||||||
|
|
||||||
|
|
||||||
class DeploymentTests(unittest.TestCase):
|
class DeploymentTests(unittest.TestCase):
|
||||||
@@ -29,6 +34,7 @@ class DeploymentTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.addCleanup(close_handlers)
|
self.addCleanup(close_handlers)
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
|
client.post('/api/visitor/session', json={'key':'1'*64})
|
||||||
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
|
for path in ('/', '/admin/dashboard', '/api/tickets', '/api/my_orders?phone=123'):
|
||||||
self.assertEqual(client.get(path).status_code, 200, path)
|
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('/admin/login', json={'username': 'demo', 'password': 'test-password'}).status_code, 401)
|
||||||
@@ -43,5 +49,80 @@ class DeploymentTests(unittest.TestCase):
|
|||||||
self.assertEqual(app.config['HOST'], '127.0.0.1')
|
self.assertEqual(app.config['HOST'], '127.0.0.1')
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyIdentityTests(unittest.TestCase):
|
||||||
|
def application(self, runtime):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
if runtime == 'docker':
|
||||||
|
command = next(line[4:] for line in (root/'Dockerfile').read_text(encoding='utf-8').splitlines()
|
||||||
|
if line.startswith('CMD '))
|
||||||
|
args = json.loads(command)[3:]
|
||||||
|
else:
|
||||||
|
command = next(line[len('ExecStart='):] for line in
|
||||||
|
(root/'deploy/baiyue-logistics-chat.service').read_text(encoding='utf-8').splitlines()
|
||||||
|
if line.startswith('ExecStart='))
|
||||||
|
args = shlex.split(command)[3:]
|
||||||
|
options, _ = Adjustments.parse_args(args)
|
||||||
|
options.pop('call'); options.pop('help')
|
||||||
|
settings = Adjustments(**options)
|
||||||
|
folder = tempfile.TemporaryDirectory(); self.addCleanup(folder.cleanup)
|
||||||
|
data = Path(folder.name)
|
||||||
|
app = create_app({'TESTING':True,'DATABASE':data/'history.db','UPLOAD_DIR':data/'uploads',
|
||||||
|
'BACKUP_DIR':data/'backups','LOG_DIR':data/'logs','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
app.config['PROXY_TEST_PEER'] = '172.18.0.1' if runtime == 'docker' else '127.0.0.1'
|
||||||
|
app.wsgi_app = proxy_headers_middleware(app.wsgi_app, trusted_proxy=settings.trusted_proxy,
|
||||||
|
trusted_proxy_count=settings.trusted_proxy_count, trusted_proxy_headers=settings.trusted_proxy_headers)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def clients(self, app):
|
||||||
|
clients = []
|
||||||
|
for address in ('198.51.100.10', '198.51.100.20'):
|
||||||
|
client = app.test_client()
|
||||||
|
client.environ_base['REMOTE_ADDR'] = app.config['PROXY_TEST_PEER']
|
||||||
|
client.environ_base['HTTP_X_FORWARDED_FOR'] = address
|
||||||
|
client.post('/api/visitor/session', json={'key':str(len(clients)+1)*64})
|
||||||
|
clients.append(client)
|
||||||
|
app.extensions['rate_hits'].clear()
|
||||||
|
return clients
|
||||||
|
|
||||||
|
def test_proxy_clients_do_not_share_request_rate_limits(self):
|
||||||
|
for runtime in ('docker', 'systemd'):
|
||||||
|
with self.subTest(runtime=runtime):
|
||||||
|
app = self.application(runtime)
|
||||||
|
a, b = self.clients(app)
|
||||||
|
app.config['RATE_LIMIT_PER_MIN'] = 2
|
||||||
|
self.assertEqual(a.get('/api/get_history').status_code, 200)
|
||||||
|
self.assertEqual(a.get('/api/get_history').status_code, 200)
|
||||||
|
self.assertEqual(a.get('/api/get_history').status_code, 429)
|
||||||
|
self.assertEqual(b.get('/api/get_history').status_code, 200)
|
||||||
|
|
||||||
|
def test_proxy_clients_do_not_share_failed_login_budgets(self):
|
||||||
|
for runtime in ('docker', 'systemd'):
|
||||||
|
with self.subTest(runtime=runtime):
|
||||||
|
app = self.application(runtime)
|
||||||
|
a, b = self.clients(app)
|
||||||
|
for _ in range(5):
|
||||||
|
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'wrong'}).status_code,403)
|
||||||
|
self.assertEqual(a.post('/api/customer/login',json={'username':'admin','password':'123456'}).status_code,429)
|
||||||
|
self.assertTrue(b.post('/api/customer/login',json={'username':'admin','password':'123456'}).json.get('authenticated'))
|
||||||
|
|
||||||
|
def test_only_the_last_forwarded_address_is_used_and_other_proxy_claims_are_ignored(self):
|
||||||
|
from flask import request
|
||||||
|
for runtime in ('docker', 'systemd'):
|
||||||
|
with self.subTest(runtime=runtime):
|
||||||
|
app = self.application(runtime)
|
||||||
|
@app.get('/proxy-probe')
|
||||||
|
def probe():
|
||||||
|
return {'ip':request.remote_addr,'host':request.host,'scheme':request.scheme}
|
||||||
|
response = app.test_client().get('/proxy-probe',base_url='https://chat.example.com',
|
||||||
|
environ_overrides={'REMOTE_ADDR':app.config['PROXY_TEST_PEER']},headers={
|
||||||
|
'X-Forwarded-For':'203.0.113.99, 198.51.100.10',
|
||||||
|
'X-Forwarded-Host':'evil.example','X-Forwarded-Proto':'http'})
|
||||||
|
self.assertEqual(response.json,{'ip':'198.51.100.10','host':'chat.example.com','scheme':'https'})
|
||||||
|
if runtime == 'systemd':
|
||||||
|
untrusted = app.test_client().get('/proxy-probe',environ_overrides={'REMOTE_ADDR':'203.0.113.1'},
|
||||||
|
headers={'X-Forwarded-For':'198.51.100.10'})
|
||||||
|
self.assertEqual(untrusted.json['ip'],'203.0.113.1')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Remote dev behavior on the modular backend and the light workspace."""
|
"""Remote dev behavior on the modular backend and the light workspace."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import io
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -21,6 +21,7 @@ class DevIntegrationTests(unittest.TestCase):
|
|||||||
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
|
'UPLOAD_DIR': root/'uploads', 'BACKUP_DIR': root/'backups', 'LOG_DIR': root/'logs',
|
||||||
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
'DEEPSEEK_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000})
|
||||||
self.client = self.app.test_client()
|
self.client = self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session', json={'key': '1' * 64})
|
||||||
|
|
||||||
def session(self):
|
def session(self):
|
||||||
cid = self.client.post('/api/new_chat').json['id']
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
@@ -49,6 +50,19 @@ class DevIntegrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
self.assertEqual(self.client.post('/api/ticket', json={'content': '周末留言'}).json, {'ok': True})
|
||||||
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
self.assertEqual(self.client.get('/api/tickets').json[0]['content'], '周末留言')
|
||||||
|
|
||||||
|
def test_service_hours_use_china_time_even_when_the_host_clock_is_utc(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
for utc_hour, expected in ((0, True),(13, True),(14, False),(23, False)):
|
||||||
|
instant = datetime(2026,10,31,utc_hour,tzinfo=timezone.utc)
|
||||||
|
class HostUtcClock(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||||
|
with self.subTest(utc_hour=utc_hour), patch('logistics_chat.human_service.datetime', HostUtcClock):
|
||||||
|
result = self.client.post('/api/visitor/human/request',json={'conv_id':cid}).json
|
||||||
|
self.assertEqual(result['ok'],expected)
|
||||||
|
if not expected: self.assertTrue(result['closed'])
|
||||||
|
|
||||||
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
def test_transfer_returns_to_queue_preserves_messages_and_note(self):
|
||||||
sid = self.session()
|
sid = self.session()
|
||||||
self.client.post('/api/human/accept', json={'sid': sid})
|
self.client.post('/api/human/accept', json={'sid': sid})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
|||||||
def test_reply_workflows(self):
|
def test_reply_workflows(self):
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs'], cwd=root,
|
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
|
||||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||||
)
|
)
|
||||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
|||||||
@@ -41,7 +41,17 @@ elif name == 'curl' and '-w' in args:
|
|||||||
if scenario == 'proxy-unsafe':
|
if scenario == 'proxy-unsafe':
|
||||||
print('200', end='')
|
print('200', end='')
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
print('401', end='')
|
path = '/' + args[-1].split('://', 1)[-1].partition('/')[2]
|
||||||
|
if '-c' in args:
|
||||||
|
pathlib.Path(args[args.index('-c') + 1]).write_text('fixture-visitor-cookie')
|
||||||
|
if '-b' in args:
|
||||||
|
assert pathlib.Path(args[args.index('-b') + 1]).read_text() == 'fixture-visitor-cookie'
|
||||||
|
status = '200' if path == '/' or path == '/api/visitor/session' or (path == '/api/get_history' and '-b' in args) else '401'
|
||||||
|
if scenario == 'visitor-bootstrap-fail' and path == '/api/visitor/session':
|
||||||
|
status = '503'
|
||||||
|
if scenario == 'visitor-history-fail' and path == '/api/get_history' and '-b' in args:
|
||||||
|
status = '503'
|
||||||
|
print(status, end='')
|
||||||
elif name == 'sleep':
|
elif name == 'sleep':
|
||||||
pass
|
pass
|
||||||
'''
|
'''
|
||||||
@@ -55,7 +65,8 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
directory = Path(temporary.name)
|
directory = Path(temporary.name)
|
||||||
root = directory / 'gray-logistics-chat'
|
root = directory / 'gray-logistics-chat'
|
||||||
proxy = directory / 'proxy'
|
proxy = directory / 'proxy'
|
||||||
for path in (root / 'data', root / 'logs', root / 'releases', proxy):
|
auth = directory / 'baiyue-logistics-chat-gray-proxy'
|
||||||
|
for path in (root / 'data', root / 'logs', root / 'releases', proxy, auth):
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||||
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
|
(root / 'data/history.db').write_bytes(b'preserve-existing-data')
|
||||||
@@ -69,12 +80,13 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
(root / 'current-release').write_text(str(previous) + '\n')
|
(root / 'current-release').write_text(str(previous) + '\n')
|
||||||
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
|
old_site = '# Managed for baiyue-logistics-chat-gray\n# previous site\n'
|
||||||
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
|
(proxy / 'baiyue-logistics-chat-gray.conf').write_text(old_site)
|
||||||
(proxy / '.baiyue-logistics-chat.htpasswd').write_text('previous-auth-hash\n')
|
(auth / '.project').write_text('another-service\n' if scenario == 'unowned-auth' else 'baiyue-logistics-chat-gray\n')
|
||||||
|
(auth / 'access.htpasswd').write_text('previous-auth-hash\n')
|
||||||
unrelated = proxy / 'unrelated.conf'
|
unrelated = proxy / 'unrelated.conf'
|
||||||
unrelated.write_text('# unrelated site\n')
|
unrelated.write_text('# unrelated site\n')
|
||||||
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
|
config = GrayConfig('chat.example.com', 29013, str(root), str(root / 'releases/new'),
|
||||||
str(proxy), str(certificate), str(private_key), 'a'*40,
|
str(proxy), str(certificate), str(private_key), 'a'*40,
|
||||||
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64)
|
'baiyue-logistics-chat-gray:' + 'a'*12 + '-b7', 'sha256:' + 'd'*64, 'sha256:' + 'c'*64, str(auth))
|
||||||
release = Path(config.release)
|
release = Path(config.release)
|
||||||
write_release(release, config, 'fixture-model-key')
|
write_release(release, config, 'fixture-model-key')
|
||||||
# Docker 26 and 29 may assign different IDs after an archive is loaded.
|
# Docker 26 and 29 may assign different IDs after an archive is loaded.
|
||||||
@@ -95,6 +107,7 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
env=environment, capture_output=True, text=True, timeout=30)
|
env=environment, capture_output=True, text=True, timeout=30)
|
||||||
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
|
self.assertEqual((root / 'data/history.db').read_bytes(), b'preserve-existing-data')
|
||||||
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
|
self.assertEqual(unrelated.read_text(), '# unrelated site\n')
|
||||||
|
self.assertFalse(list(release.glob('.visitor-smoke.*')), 'Temporary visitor cookies must be removed on success and rollback')
|
||||||
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
calls = [json.loads(line) for line in log.read_text().splitlines()]
|
||||||
return result, root, proxy, previous, release, old_site, calls
|
return result, root, proxy, previous, release, old_site, calls
|
||||||
|
|
||||||
@@ -103,15 +116,21 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertEqual((root / 'current-release').read_text().strip(), str(release))
|
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())
|
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):
|
def assert_rolled_back(self, scenario):
|
||||||
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
|
result, root, proxy, previous, release, old_site, calls = self.run_activation(scenario)
|
||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
self.assertEqual((root / 'current-release').read_text().strip(), str(previous))
|
||||||
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
||||||
self.assertEqual((proxy / '.baiyue-logistics-chat.htpasswd').read_text(), 'previous-auth-hash\n')
|
self.assertEqual((proxy.parent / 'baiyue-logistics-chat-gray-proxy/access.htpasswd').read_text(), 'previous-auth-hash\n')
|
||||||
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
|
self.assertTrue(any(name == 'docker' and 'up' in args and str(previous / 'release.env') in args
|
||||||
for name, args in calls), result.stderr)
|
for name, args in calls), result.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
def test_failed_application_restores_previous_release(self):
|
def test_failed_application_restores_previous_release(self):
|
||||||
self.assert_rolled_back('app-fail')
|
self.assert_rolled_back('app-fail')
|
||||||
@@ -125,6 +144,14 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
|
def test_unprotected_proxy_never_publishes_the_release_pointer(self):
|
||||||
self.assert_rolled_back('proxy-unsafe')
|
self.assert_rolled_back('proxy-unsafe')
|
||||||
|
|
||||||
|
def test_failed_visitor_bootstrap_rolls_back_and_removes_cookie_file(self):
|
||||||
|
result = self.assert_rolled_back('visitor-bootstrap-fail')
|
||||||
|
self.assertIn('Guest session bootstrap verification failed.', result.stderr)
|
||||||
|
|
||||||
|
def test_failed_cookie_history_rolls_back_and_removes_cookie_file(self):
|
||||||
|
result = self.assert_rolled_back('visitor-history-fail')
|
||||||
|
self.assertIn('Guest history verification failed.', result.stderr)
|
||||||
|
|
||||||
def test_image_retagged_after_verified_load_is_rejected_before_activation(self):
|
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')
|
result, root, proxy, previous, release, old_site, calls = self.run_activation('image-mismatch')
|
||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
@@ -132,6 +159,15 @@ class GrayActivationTests(unittest.TestCase):
|
|||||||
self.assertEqual((proxy / 'baiyue-logistics-chat-gray.conf').read_text(), old_site)
|
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))
|
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):
|
def test_invalid_nginx_configuration_restores_previous_site_and_application(self):
|
||||||
self.assert_rolled_back('proxy-fail')
|
self.assert_rolled_back('proxy-fail')
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import replace
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import re
|
||||||
|
|
||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
@@ -57,6 +58,32 @@ class GrayConfigTests(unittest.TestCase):
|
|||||||
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
|
self.assertNotIn(key, (path / name).read_text(encoding='utf-8'))
|
||||||
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
|
self.assertIn('auth_basic_user_file', (path / 'site.conf').read_text(encoding='utf-8'))
|
||||||
|
|
||||||
|
def test_public_allowlist_keeps_staff_search_exports_and_order_endpoints_protected(self):
|
||||||
|
config = GrayConfig.from_environment(self.settings()).nginx_config()
|
||||||
|
self.assertTrue(config.startswith('# Managed for baiyue-logistics-chat-gray;'))
|
||||||
|
match = re.search(r'location ~ (\S+) \{([^}]+)\}', config)
|
||||||
|
self.assertIsNotNone(match)
|
||||||
|
pattern, body = match.groups()
|
||||||
|
self.assertIn('auth_basic off;', body)
|
||||||
|
self.assertIn('auth_basic "Baiyue gray acceptance";', config)
|
||||||
|
self.assertIn('proxy_set_header X-Forwarded-For $remote_addr;',config)
|
||||||
|
self.assertNotIn('$proxy_add_x_forwarded_for',config)
|
||||||
|
compose=(Path(__file__).resolve().parents[1]/'deploy/compose.gray.yml').read_text(encoding='utf-8')
|
||||||
|
self.assertIn('"127.0.0.1:${HOST_PORT:?HOST_PORT is required}:5000"',compose)
|
||||||
|
for path in ('/healthz','/stream_chat','/api/visitor/session','/api/get_history','/api/get_messages',
|
||||||
|
'/api/customer/session','/api/customer/login','/api/customer/logout',
|
||||||
|
'/api/del_history','/api/clear_all','/api/visitor/human/request','/api/visitor/human/poll',
|
||||||
|
'/api/visitor/human/send','/api/visitor/human/close','/api/speech/transcribe'):
|
||||||
|
with self.subTest(public_path=path):self.assertRegex(path,pattern)
|
||||||
|
for path in ('/admin/dashboard','/api/export_chat','/api/search_chat','/api/my_orders','/api/tickets',
|
||||||
|
'/api/human/queue','/api/human/poll','/api/human/send','/api/staff/uploads/private.txt',
|
||||||
|
'/api/visitor/human/accept','/api/customer/users','/api/customer/session/extra',
|
||||||
|
'/api/get_history/unknown','/api/unknown'):
|
||||||
|
with self.subTest(staff_path=path):self.assertNotRegex(path,pattern)
|
||||||
|
example=(Path(__file__).resolve().parents[1]/'deploy/nginx.conf.example').read_text(encoding='utf-8')
|
||||||
|
self.assertEqual(re.search(r'location ~ (\S+) \{',example)[1],pattern)
|
||||||
|
self.assertIn('location = / {\n auth_basic off;',config)
|
||||||
|
|
||||||
def test_invalid_model_credential_does_not_create_a_release_directory(self):
|
def test_invalid_model_credential_does_not_create_a_release_directory(self):
|
||||||
config = GrayConfig.from_environment(self.settings())
|
config = GrayConfig.from_environment(self.settings())
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
@@ -65,6 +92,32 @@ class GrayConfigTests(unittest.TestCase):
|
|||||||
write_release(path, config, 'fixture-key\nANOTHER_SETTING=bad')
|
write_release(path, config, 'fixture-key\nANOTHER_SETTING=bad')
|
||||||
self.assertFalse(path.exists())
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
def test_baidu_credentials_only_enter_private_runtime_file(self):
|
||||||
|
config = GrayConfig.from_environment(self.settings())
|
||||||
|
speech = {'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'fixture-${asr-key}',
|
||||||
|
'BAIDU_ASR_SECRET_KEY': "fixture-asr-secret's", 'FFMPEG_BIN': r'C:\local\ffmpeg.exe'}
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'release'
|
||||||
|
write_release(path, config, 'fixture-model-key', speech)
|
||||||
|
runtime = dotenv_values(path / 'runtime.env', interpolate=False)
|
||||||
|
self.assertEqual(runtime['ASR_PROVIDER'], 'baidu')
|
||||||
|
for key in ('BAIDU_ASR_API_KEY', 'BAIDU_ASR_SECRET_KEY'):
|
||||||
|
self.assertEqual(runtime[key], speech[key])
|
||||||
|
for file in path.iterdir():
|
||||||
|
if file.name != 'runtime.env':
|
||||||
|
self.assertNotIn(speech[key], file.read_text(encoding='utf-8'))
|
||||||
|
self.assertNotIn('FFMPEG_BIN', runtime)
|
||||||
|
|
||||||
|
def test_incomplete_or_multiline_speech_credentials_are_rejected_before_writing(self):
|
||||||
|
config = GrayConfig.from_environment(self.settings())
|
||||||
|
for speech in ({'ASR_PROVIDER': 'baidu'}, {'ASR_PROVIDER': 'unknown'},
|
||||||
|
{'ASR_PROVIDER': 'baidu', 'BAIDU_ASR_API_KEY': 'key', 'BAIDU_ASR_SECRET_KEY': 'secret\nOTHER=bad'}):
|
||||||
|
with self.subTest(provider=speech['ASR_PROVIDER']), tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'release'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
write_release(path, config, 'fixture-key', speech)
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Run the real private-import guard against a disposable database and fake Docker."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from logistics_chat.order_data import import_records
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == 'posix' and shutil.which('bash'), 'Private import requires Linux Bash')
|
||||||
|
class GrayOrderImportTests(unittest.TestCase):
|
||||||
|
def run_import(self, corrupt=False):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
directory = Path(temporary.name)
|
||||||
|
root = directory / 'gray-logistics-chat'
|
||||||
|
release_id = 'a' * 12 + '-b7'
|
||||||
|
release = root / 'releases' / release_id
|
||||||
|
release.mkdir(parents=True)
|
||||||
|
(root / '.project').write_text('baiyue-logistics-chat-gray\n')
|
||||||
|
(root / 'current-release').write_text(str(release) + '\n')
|
||||||
|
data = root / 'data'
|
||||||
|
(data / 'backups').mkdir(parents=True)
|
||||||
|
database = data / 'history.db'
|
||||||
|
with sqlite3.connect(database) as connection:
|
||||||
|
connection.execute('CREATE TABLE messages (content TEXT)')
|
||||||
|
connection.execute("INSERT INTO messages VALUES ('keep chat history')")
|
||||||
|
import_records(connection, [{'order_no': 'EXISTING1', 'source_order_id': '1',
|
||||||
|
'tenant_id': 'test', 'gross_weight_kg': 'manual weight',
|
||||||
|
'tracking_json': [{'manual': True}]}])
|
||||||
|
orders = [{'order_no': 'EXISTING1', 'source_order_id': '1', 'tenant_id': 'test', 'gross_weight_kg': 'source weight'},
|
||||||
|
{'order_no': 'NEW00002', 'source_order_id': '2', 'tenant_id': 'test'}]
|
||||||
|
tracking = [{'order_no': row['order_no'], 'source_order_id': row['source_order_id'],
|
||||||
|
'tenant_id': 'test', 'tracking_json': [{'code': 'IN_TRANSIT', 'actual_at': '2026-10-30'}]}
|
||||||
|
for row in orders]
|
||||||
|
hashes = []
|
||||||
|
for name, records in [('order-input.json', orders), ('tracking-input.json', tracking)]:
|
||||||
|
path = release / name
|
||||||
|
path.write_text(json.dumps({'orders': records}))
|
||||||
|
hashes.append(hashlib.sha256(path.read_bytes()).hexdigest())
|
||||||
|
if corrupt:
|
||||||
|
(release / 'order-input.json').write_text('changed in transit')
|
||||||
|
commands = directory / 'commands'
|
||||||
|
commands.mkdir()
|
||||||
|
docker = commands / 'docker'
|
||||||
|
docker.write_text('#!' + sys.executable + '\n' + '''import os, pathlib, subprocess, sys
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args[0] == 'inspect':
|
||||||
|
print('a'*40 if 'revision' in args[2] else 'baiyue-logistics-chat-gray')
|
||||||
|
elif args[0] == 'exec':
|
||||||
|
command = [sys.executable] + args[3:]
|
||||||
|
command = [value.replace('/var/lib/baiyue-logistics-chat', os.environ['FAKE_DATA']) for value in command]
|
||||||
|
if len(command) > 1 and command[1] != '-c':
|
||||||
|
command[1] = str(pathlib.Path(os.environ['FAKE_SOURCE']) / command[1])
|
||||||
|
sys.exit(subprocess.run(command).returncode)
|
||||||
|
else:
|
||||||
|
sys.exit(3)
|
||||||
|
''')
|
||||||
|
docker.chmod(0o755)
|
||||||
|
source = Path(__file__).resolve().parents[1]
|
||||||
|
script = (source / 'deploy/import-gray-order-samples.sh').read_text()
|
||||||
|
remote = script.split("<<'IMPORT'\n", 1)[1].split('\nIMPORT\n', 1)[0]
|
||||||
|
environment = dict(os.environ, PATH=str(commands) + os.pathsep + os.environ['PATH'],
|
||||||
|
FAKE_DATA=str(data), FAKE_SOURCE=str(source))
|
||||||
|
result = subprocess.run(['bash', '-s', '--', str(root), release_id, 'a' * 40, *hashes],
|
||||||
|
input=remote, env=environment, capture_output=True, text=True, timeout=30)
|
||||||
|
return result, data, release, release_id
|
||||||
|
|
||||||
|
def test_import_backs_up_and_preserves_manual_fields_tracking_and_chat_history(self):
|
||||||
|
result, data, release, release_id = self.run_import()
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
with sqlite3.connect(data / 'history.db') as connection:
|
||||||
|
self.assertEqual(connection.execute('SELECT content FROM messages').fetchall(), [('keep chat history',)])
|
||||||
|
row = connection.execute('SELECT gross_weight_kg,tracking_json FROM order_test_data WHERE order_no="EXISTING1"').fetchone()
|
||||||
|
self.assertEqual(row[0], 'manual weight')
|
||||||
|
self.assertEqual(json.loads(row[1]), [{'manual': True}])
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 2)
|
||||||
|
row = connection.execute('SELECT tracking_json FROM order_test_data WHERE order_no="NEW00002"').fetchone()
|
||||||
|
self.assertEqual(json.loads(row[0])[0]['code'], 'IN_TRANSIT')
|
||||||
|
with sqlite3.connect(data / 'backups' / ('pre-order-import-' + release_id + '.db')) as backup:
|
||||||
|
self.assertEqual(backup.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
|
||||||
|
self.assertFalse((data / 'imports' / release_id).exists())
|
||||||
|
self.assertFalse((release / 'order-input.json').exists())
|
||||||
|
self.assertFalse((release / 'tracking-input.json').exists())
|
||||||
|
|
||||||
|
def test_corrupt_snapshot_is_rejected_before_database_changes(self):
|
||||||
|
result, data, release, release_id = self.run_import(corrupt=True)
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
with sqlite3.connect(data / 'history.db') as connection:
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0], 1)
|
||||||
|
self.assertFalse(list((data / 'backups').iterdir()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Order counts are fresh, customer-scoped facts with protected history."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db, order_data
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCountTests(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':'',
|
||||||
|
'CUSTOMER_DEMO_TENANT_ID':'tenant-a','ORDER_DATA_TENANT_ID':'','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.a, self.b = self.app.test_client(), self.app.test_client()
|
||||||
|
for client,key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||||
|
client.post('/api/visitor/session',json={'key':key})
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
order_data.import_records(connection,[
|
||||||
|
{'tenant_id':'tenant-a','order_no':'COUNT20260001','inbound_code':'INB20260001','status':'PROCESSING'},
|
||||||
|
{'tenant_id':'tenant-a','order_no':'COUNT20260002','status':'COMPLETED'},
|
||||||
|
{'tenant_id':'tenant-b','order_no':'COUNT20260001','inbound_code':'INB20260099','status':'PROCESSING'},
|
||||||
|
{'tenant_id':'ungranted','order_no':'COUNT20260003','status':'COMPLETED'}])
|
||||||
|
connection.execute('INSERT INTO customer_users(username,password_hash,display_name,tenant_id) VALUES(?,?,?,?)',
|
||||||
|
('other',self.app.config['CUSTOMER_DEMO_PASSWORD_HASH'],'Other','tenant-b'))
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
|
||||||
|
def login(self, client=None, username='admin'):
|
||||||
|
self.assertTrue((client or self.a).post('/api/customer/login',json={'username':username,'password':'123456'}).json['ok'])
|
||||||
|
|
||||||
|
def events(self, message, cid=None, client=None):
|
||||||
|
response = (client or self.a).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_guest_counts_require_login_before_reading_any_counts(self):
|
||||||
|
with patch('logistics_chat.order_data.count_orders') as count, patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for question in ('我有多少个运单','我的订单数量','我今天有多少订单','How many shipments do I have?'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
self.assertTrue(self.events(question)[0]['login_required'])
|
||||||
|
count.assert_not_called(); model.assert_not_called()
|
||||||
|
|
||||||
|
def test_order_list_is_login_scoped_status_filtered_and_protects_history(self):
|
||||||
|
self.assertTrue(self.events('列出我的全部订单')[0]['login_required'])
|
||||||
|
self.login()
|
||||||
|
cid=self.a.post('/api/new_chat').json['id']
|
||||||
|
text=self.events('列出我的全部订单',cid)[0]['t']
|
||||||
|
self.assertIn('COUNT20260001',text);self.assertIn('COUNT20260002',text)
|
||||||
|
self.assertNotIn('INB20260099',text);self.assertNotIn('COUNT20260003',text)
|
||||||
|
text=self.events('列出已完成的订单')[0]['t']
|
||||||
|
self.assertIn('COUNT20260002',text);self.assertNotIn('COUNT20260001',text)
|
||||||
|
self.assertIn('暂不支持按这个条件',self.events('列出今天的订单')[0]['t'])
|
||||||
|
self.a.post('/api/customer/logout')
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
|
||||||
|
def test_address_change_does_not_claim_a_mutation_or_bypass_login(self):
|
||||||
|
message='帮我把INB20260001收件地址改成新地址'
|
||||||
|
self.assertTrue(self.events(message)[0]['login_required'])
|
||||||
|
self.login()
|
||||||
|
events=self.events(message)
|
||||||
|
self.assertIn('尚未修改订单',events[0]['t'])
|
||||||
|
self.assertFalse(any('card' in e for e in events))
|
||||||
|
|
||||||
|
def test_logged_in_counts_accept_common_wording_and_refresh_after_data_changes(self):
|
||||||
|
self.login()
|
||||||
|
cid = self.a.post('/api/new_chat').json['id']
|
||||||
|
self.events('INB20260001',cid)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for question in ('我有多少个运单','我一共有几票订单?','请帮我统计一下我的订单数量',
|
||||||
|
'我的运单有多少','运单数','我有多少入仓码','我想知道我有多少个运单',
|
||||||
|
'能帮我查下我的运单总数吗','帮我统计下我名下的订单','How many shipments do I have?',
|
||||||
|
'What is the number of my orders?'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
events = self.events(question,cid)
|
||||||
|
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
|
||||||
|
self.assertEqual(events[-1],{'d':1})
|
||||||
|
model.assert_not_called()
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||||
|
|
||||||
|
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
|
||||||
|
self.login()
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
completed = self.events('我有多少个已完成的订单')[0]['t']
|
||||||
|
self.assertIn('1 票业务状态为“头程订单已完成”',completed)
|
||||||
|
self.assertIn('0 票已登记入仓码',completed)
|
||||||
|
self.assertNotIn('签收',completed)
|
||||||
|
self.assertIn('1 票业务状态为“履约中”',self.events('履约中的运单有多少')[0]['t'])
|
||||||
|
self.assertIn('0 票业务状态为“已取消”',self.events('我有多少已取消订单')[0]['t'])
|
||||||
|
for question in ('我今天有多少订单','我有多少已签收的运单','美国运单有多少',
|
||||||
|
'我有多少未完成的订单','已完成和已取消各有多少订单'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
result = self.events(question)[0]
|
||||||
|
self.assertIn('暂不支持按这个条件统计',result['t'])
|
||||||
|
self.assertNotIn('您当前共有',result['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
for question in ('这个订单重量有多少','订单运费是多少','订单数量是什么意思'):
|
||||||
|
self.assertIsNone(order_data.parse_count_query(question))
|
||||||
|
|
||||||
|
def test_multiple_grants_and_environment_restrictions_use_the_same_scope_as_lookup(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE customer_users SET tenant_id='',scope_initialized=1 WHERE username='admin'")
|
||||||
|
user = connection.execute("SELECT id FROM customer_users WHERE username='admin'").fetchone()[0]
|
||||||
|
connection.executemany('INSERT INTO customer_tenant_grants(customer_id,tenant_id) VALUES(?,?)',
|
||||||
|
[(user,'tenant-a'),(user,'tenant-b')])
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
self.login()
|
||||||
|
self.assertIn('共有 3 票订单,其中 2 票',self.events('我有多少个运单')[0]['t'])
|
||||||
|
forged = self.a.post('/stream_chat',json={'msg':'我有多少个运单','tenant_id':'ungranted'},headers={'X-Tenant-Id':'ungranted'})
|
||||||
|
self.assertIn('共有 3 票订单',forged.get_data(as_text=True))
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID']='tenant-b'
|
||||||
|
self.assertTrue(self.events('我有多少个运单')[0]['login_required'])
|
||||||
|
self.login()
|
||||||
|
self.assertIn('共有 1 票订单,其中 1 票',self.events('我有多少个运单')[0]['t'])
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual(order_data.count_orders(('tenant-a','tenant-b')),{'total':1,'with_inbound':1})
|
||||||
|
for scope in (None,(),[],['ungranted'],'tenant-b'):
|
||||||
|
self.assertEqual(order_data.count_orders(scope),{'total':0,'with_inbound':0})
|
||||||
|
|
||||||
|
def test_count_history_is_bound_even_without_an_order_card(self):
|
||||||
|
self.login(); cid = self.a.post('/api/new_chat').json['id']
|
||||||
|
events = self.events('我有多少个运单',cid)
|
||||||
|
self.assertNotIn('_customer_data',json.dumps(events))
|
||||||
|
self.a.post('/api/customer/logout')
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||||
|
self.a.delete('/api/clear_all')
|
||||||
|
self.login(username='other')
|
||||||
|
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.login(self.b)
|
||||||
|
self.assertEqual(self.b.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
self.login()
|
||||||
|
self.assertTrue(any('共有 2 票订单' in m['content'] for m in self.a.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||||||
|
|
||||||
|
def test_count_failure_and_failed_history_binding_never_emit_facts(self):
|
||||||
|
self.login(); cid = self.a.post('/api/new_chat').json['id']
|
||||||
|
with patch('logistics_chat.customer_service.bind_order_conversation',return_value=False):
|
||||||
|
events = self.events('我有多少个运单',cid)
|
||||||
|
self.assertIn('无法访问',events[0]['e'])
|
||||||
|
self.assertNotIn('共有',json.dumps(events))
|
||||||
|
with patch('logistics_chat.db.connect',side_effect=sqlite3.OperationalError('private diagnostic')):
|
||||||
|
with self.app.app_context():
|
||||||
|
with self.assertRaises(order_data.OrderDataUnavailable):
|
||||||
|
order_data.count_orders(('tenant-a',))
|
||||||
|
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
|
||||||
|
events = self.events('我有多少个运单')
|
||||||
|
self.assertIn('暂时无法读取',events[0]['t'])
|
||||||
|
|
||||||
|
def test_empty_data_and_blank_codes_are_counted_truthfully(self):
|
||||||
|
self.login()
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute('UPDATE order_test_data SET inbound_code=? WHERE order_no=? AND tenant_id=?',
|
||||||
|
(' \t\r\n','COUNT20260002','tenant-a'))
|
||||||
|
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 0 票订单,其中 0 票已登记入仓码。')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Local order snapshots, exact lookup and conversational order queries."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db, order_data
|
||||||
|
from logistics_chat.tracking import CARD_PREFIX, card_to_text
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDataTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
folder = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(folder.cleanup)
|
||||||
|
root = Path(folder.name)
|
||||||
|
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db',
|
||||||
|
'UPLOAD_DIR':root/'uploads', 'BACKUP_DIR':root/'backups', 'LOG_DIR':root/'logs',
|
||||||
|
'DEEPSEEK_API_KEY':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||||
|
self.context = self.app.app_context()
|
||||||
|
self.context.push()
|
||||||
|
self.addCleanup(self.context.pop)
|
||||||
|
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||||||
|
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||||||
|
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
|
||||||
|
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
|
||||||
|
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||||||
|
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||||||
|
self.import_rows([self.record])
|
||||||
|
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||||
|
self.client.get('/api/customer/session')
|
||||||
|
|
||||||
|
def import_rows(self, rows, replace=False):
|
||||||
|
connection = db.connect()
|
||||||
|
try:
|
||||||
|
with connection:
|
||||||
|
return order_data.import_records(connection, rows, replace=replace)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def events(self, message, cid=None):
|
||||||
|
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
|
||||||
|
|
||||||
|
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
|
||||||
|
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
|
||||||
|
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
|
||||||
|
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
|
||||||
|
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
|
||||||
|
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
|
||||||
|
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
|
||||||
|
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
|
||||||
|
|
||||||
|
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
|
||||||
|
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
|
||||||
|
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
|
||||||
|
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||||
|
self.assertEqual(order_data.extract_reference(text), [])
|
||||||
|
events = self.events('帮我查询物流轨迹')
|
||||||
|
self.assertIn('入仓码', events[0]['t'])
|
||||||
|
self.assertEqual(events[-1], {'d':1})
|
||||||
|
|
||||||
|
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
|
||||||
|
cid=self.client.post('/api/new_chat').json['id']
|
||||||
|
card=self.events(self.record['inbound_code'],cid)[0]['card']
|
||||||
|
self.assertEqual(card['no'],self.record['order_no'])
|
||||||
|
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||||
|
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
|
||||||
|
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
|
||||||
|
followup=self.events('现在到哪了',cid)[0]['card']
|
||||||
|
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||||
|
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
|
||||||
|
|
||||||
|
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
|
||||||
|
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
|
||||||
|
card=self.events(self.record['order_no'])[0]['card']
|
||||||
|
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
|
||||||
|
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
|
||||||
|
|
||||||
|
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||||
|
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||||
|
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||||
|
'FL202610290001 运费多少钱?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
event = self.events(message)[0]
|
||||||
|
if '运费' in message:
|
||||||
|
self.assertIn('未记录实际运费', event['t'])
|
||||||
|
else:
|
||||||
|
self.assertEqual(event['card']['no'], self.record['order_no'])
|
||||||
|
|
||||||
|
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = self.events('查询 UNKNOWN20260001')
|
||||||
|
self.assertIn('未找到', events[0]['t'])
|
||||||
|
self.assertFalse(any('card' in event for event in events))
|
||||||
|
self.assertEqual(events[-1], {'d':1})
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_bulk_query_lists_only_existing_orders_without_model(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events = self.events('列出所有订单')
|
||||||
|
self.assertIn(self.record['inbound_code'], events[0]['t'])
|
||||||
|
self.assertIn(self.record['order_no'], events[0]['t'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||||
|
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
|
||||||
|
fields = self.events(self.record['order_no'])[0]['card']['fields']
|
||||||
|
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '30.000 kg')
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
|
||||||
|
rows = order_data.lookup_order(self.record['order_no'])
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
|
||||||
|
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
|
||||||
|
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
|
||||||
|
|
||||||
|
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
|
||||||
|
fields = self.events(self.record['order_no'])[0]['card']['fields']
|
||||||
|
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
|
||||||
|
self.import_rows([self.record], replace=True)
|
||||||
|
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
|
||||||
|
|
||||||
|
def test_new_table_records_can_be_added_without_touching_source_database(self):
|
||||||
|
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
|
||||||
|
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
|
||||||
|
|
||||||
|
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
|
||||||
|
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
|
||||||
|
card = self.events(self.record['order_no'])[0]['card']
|
||||||
|
self.assertEqual(card['status'], '头程订单已完成')
|
||||||
|
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
|
||||||
|
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
|
||||||
|
self.assertTrue(all(not node['place'] for node in card['nodes']))
|
||||||
|
|
||||||
|
def test_children_do_not_reuse_parent_total_weight(self):
|
||||||
|
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
|
||||||
|
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
|
||||||
|
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
|
||||||
|
card = self.events('CHILD20260001-01')[0]['card']
|
||||||
|
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
|
||||||
|
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
|
||||||
|
|
||||||
|
def test_tracking_queries_and_followups_read_linked_facts_without_calling_model(self):
|
||||||
|
self.import_rows([{**self.record, 'tracking_json': [
|
||||||
|
{'code': 'DEPARTED_ORIGIN_PORT', 'actual_at': '2026-10-28 10:00', 'shipment_key': '1'},
|
||||||
|
{'code': 'ARRIVED_DESTINATION_PORT', 'estimated_at': '2026-11-02 10:00', 'shipment_key': '1'},
|
||||||
|
]}], replace=True)
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for message in (self.record['order_no']+' 物流轨迹', '到哪里了?', '那到港了吗?', '这票签收了吗?',
|
||||||
|
'轨迹呢?', '现在到哪了?', '什么时候到港?', '预计什么时候到?', '还要多久?'):
|
||||||
|
card = self.events(message, cid)[0]['card']
|
||||||
|
self.assertEqual(card['node_kind'], 'logistics')
|
||||||
|
self.assertEqual(card['tracking_status'], '船舶已离开起运港')
|
||||||
|
self.assertEqual(card['nodes'][0]['text'], '船舶已离开起运港')
|
||||||
|
self.assertEqual(card['estimated_nodes'][0]['text'], '预计到达目的港')
|
||||||
|
self.assertIn('预计到达目的港', card_to_text(CARD_PREFIX+json.dumps(card)))
|
||||||
|
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
|
||||||
|
model.assert_not_called()
|
||||||
|
self.assertIsNone(order_data.answer_query('美国30KG报价', cid))
|
||||||
|
|
||||||
|
def test_followups_refresh_local_data_and_remain_within_conversation(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
connection = db.connect()
|
||||||
|
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
card = self.events('这票重量是多少?',cid)[0]['card']
|
||||||
|
self.assertEqual(card['no'], self.record['order_no'])
|
||||||
|
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
|
||||||
|
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
|
||||||
|
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
|
||||||
|
self.events('查询 UNKNOWN20260001',cid)
|
||||||
|
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
|
||||||
|
other_cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
|
||||||
|
|
||||||
|
def test_new_shipping_questions_do_not_reuse_previous_order(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
self.assertIsNone(order_data.answer_query(message, cid))
|
||||||
|
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||||||
|
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||||||
|
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
|
||||||
|
|
||||||
|
def test_short_order_followups_read_facts_without_calling_model(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['order_no'], cid)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
|
||||||
|
with self.subTest(message=message):
|
||||||
|
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_tracking_aliases_reuse_only_the_current_orders_context(self):
|
||||||
|
cid = self.client.post('/api/new_chat').json['id']
|
||||||
|
self.events(self.record['inbound_code'], cid)
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for question in ('查物流', '帮我查一下物流', '物流查询', '帮我查下轨迹', '查一下进度',
|
||||||
|
'我的货到哪了', '这票货什么时候到?', '这票状态是什么?', '这个目的仓是什么?',
|
||||||
|
'Where is my parcel?', 'What is my shipment status?'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
self.assertEqual(self.events(question, cid)[0]['card']['no'], self.record['order_no'])
|
||||||
|
model.assert_not_called()
|
||||||
|
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||||||
|
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||||||
|
self.assertIn('请提供入仓码', self.events('查物流', cid)[0]['t'])
|
||||||
|
for question in ('物流轨迹是什么意思?', '物流查询怎么操作?', '怎么查物流?',
|
||||||
|
'海运一般多久到?', '包裹丢了怎么办?', 'What is parcel tracking?', '入仓码是什么?'):
|
||||||
|
with self.subTest(general_question=question):
|
||||||
|
self.assertIsNone(order_data.answer_query(question, None))
|
||||||
|
|
||||||
|
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,102 @@
|
|||||||
|
"""Tracking facts preserve actual/estimated meaning, corrections and order identity."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import unittest
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
from logistics_chat.order_data import import_records, import_tracking_records
|
||||||
|
from logistics_chat.order_tracking import build_tracking
|
||||||
|
|
||||||
|
|
||||||
|
class OrderTrackingTests(unittest.TestCase):
|
||||||
|
def fact(self, code, actual=None, estimated=None, **extra):
|
||||||
|
return {'code': code, 'actual_at': actual, 'estimated_at': estimated,
|
||||||
|
'observed_at': '2026-10-29 12:00', 'shipment_key': 'container-1', **extra}
|
||||||
|
|
||||||
|
def test_estimates_do_not_advance_actual_progress_or_fill_missing_milestones(self):
|
||||||
|
result = build_tracking([
|
||||||
|
self.fact('DEPARTED_ORIGIN_PORT', '2026-10-25 10:00'),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-01 10:00'),
|
||||||
|
])
|
||||||
|
self.assertEqual(result['status'], '船舶已离开起运港')
|
||||||
|
self.assertEqual([n['text'] for n in result['nodes']], ['船舶已离开起运港'])
|
||||||
|
self.assertEqual(result['estimated_nodes'][0]['text'], '预计到达目的港')
|
||||||
|
self.assertEqual(result['estimated_nodes'][0]['time'], '2026-11-01 10:00')
|
||||||
|
|
||||||
|
def test_latest_observation_can_correct_actual_time_backwards(self):
|
||||||
|
result = build_tracking([
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-28 10:00'),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', '2026-10-27 10:00', observed_at='2026-10-30 10:00'),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-31 10:00', observed_at='2026-10-31 10:00'),
|
||||||
|
])
|
||||||
|
self.assertEqual(len(result['nodes']), 1)
|
||||||
|
self.assertEqual(result['nodes'][0]['time'], '2026-10-27 10:00')
|
||||||
|
self.assertEqual(result['estimated_nodes'], [])
|
||||||
|
|
||||||
|
def test_delayed_earlier_milestone_and_stale_forecast_do_not_regress_progress(self):
|
||||||
|
result = build_tracking([
|
||||||
|
self.fact('PICKED_UP', '2026-10-28 10:00'),
|
||||||
|
self.fact('AT_ORIGIN_PORT', '2026-10-29 10:00', observed_at='2026-10-30 10:00'),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-29 20:00'),
|
||||||
|
])
|
||||||
|
self.assertEqual(result['status'], '已提柜')
|
||||||
|
self.assertEqual(len(result['nodes']), 2)
|
||||||
|
self.assertEqual(result['estimated_nodes'], [])
|
||||||
|
self.assertNotIn('签收', json.dumps(result, ensure_ascii=False))
|
||||||
|
|
||||||
|
def test_multiple_containers_keep_independent_progress_and_locations(self):
|
||||||
|
result = build_tracking([
|
||||||
|
self.fact('PICKED_UP', '2026-10-28 10:00', shipment_no='BOX-A', place='码头 A'),
|
||||||
|
self.fact('IN_TRANSIT', '2026-10-27 10:00', shipment_key='container-2', shipment_no='BOX-B'),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00',
|
||||||
|
shipment_key='container-2', shipment_no='BOX-B'),
|
||||||
|
])
|
||||||
|
self.assertEqual(result['status'], '货柜 BOX-A:已提柜;货柜 BOX-B:海运途中')
|
||||||
|
self.assertEqual(result['nodes'][0]['place'], '货柜 BOX-A · 码头 A')
|
||||||
|
self.assertEqual(result['estimated_nodes'][0]['place'], '货柜 BOX-B')
|
||||||
|
|
||||||
|
def test_domestic_actual_is_retained_when_ship_has_only_forecasts(self):
|
||||||
|
result = build_tracking([
|
||||||
|
self.fact('DOMESTIC_RECEIVED', '2026-10-28 10:00', shipment_key=''),
|
||||||
|
self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00'),
|
||||||
|
])
|
||||||
|
self.assertEqual(result['status'], '已入国内仓')
|
||||||
|
self.assertEqual(len(result['nodes']), 1)
|
||||||
|
|
||||||
|
def test_estimated_only_unknown_codes_and_broken_values_are_safe(self):
|
||||||
|
result = build_tracking([self.fact('PICKED_UP', estimated='2026-11-02 10:00'),
|
||||||
|
{'code': []}, None, self.fact('UNRECOGNIZED', '2026-10-28 10:00'),
|
||||||
|
self.fact('IN_TRANSIT', 'not a time')])
|
||||||
|
self.assertEqual(result['status'], '')
|
||||||
|
self.assertEqual(result['nodes'], [])
|
||||||
|
self.assertEqual(result['estimated_nodes'][0]['text'], '预计提柜')
|
||||||
|
for value in (None, {}, 'broken json', '[null]', []):
|
||||||
|
self.assertIsNone(build_tracking(value))
|
||||||
|
|
||||||
|
def test_tracking_import_preserves_manual_order_fields_and_scopes_by_identity(self):
|
||||||
|
with closing(sqlite3.connect(':memory:')) as connection:
|
||||||
|
orders = [{'order_no': 'ORDER2026001', 'source_order_id': '987654321123456789',
|
||||||
|
'tenant_id': tenant, 'gross_weight_kg': weight}
|
||||||
|
for tenant, weight in [('10', '42'), ('20', '80')]]
|
||||||
|
import_records(connection, orders)
|
||||||
|
record = {**orders[0], 'tracking_json': [self.fact('IN_TRANSIT', '2026-10-28 10:00')]}
|
||||||
|
self.assertEqual(import_tracking_records(connection, [record]),
|
||||||
|
{'imported': 1, 'preserved': 0, 'missing': 0})
|
||||||
|
self.assertEqual(connection.execute('SELECT gross_weight_kg FROM order_test_data '
|
||||||
|
'WHERE tenant_id=\'10\'').fetchone()[0], '42')
|
||||||
|
self.assertEqual(connection.execute('SELECT tracking_json FROM order_test_data '
|
||||||
|
'WHERE tenant_id=\'20\'').fetchone()[0], '[]')
|
||||||
|
self.assertEqual(import_tracking_records(connection, [record])['preserved'], 1)
|
||||||
|
self.assertEqual(import_tracking_records(connection, [record], replace=True)['imported'], 1)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
import_tracking_records(connection, [{**record, 'source_order_id': 'wrong'}], replace=True)
|
||||||
|
self.assertEqual(import_tracking_records(connection, [{**record, 'order_no': 'MISSING2026001'}])['missing'], 1)
|
||||||
|
|
||||||
|
def test_old_table_is_migrated_without_losing_data(self):
|
||||||
|
with closing(sqlite3.connect(':memory:')) as connection:
|
||||||
|
import_records(connection, [{'tenant_id': '10', 'order_no': 'ORDER2026001'}])
|
||||||
|
connection.execute('ALTER TABLE order_test_data DROP COLUMN tracking_json')
|
||||||
|
import_tracking_records(connection, [])
|
||||||
|
self.assertEqual(connection.execute('SELECT order_no,tracking_json FROM order_test_data').fetchone(),
|
||||||
|
('ORDER2026001', '[]'))
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Customer-language regressions use synthetic amounts, never private tariffs."""
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db
|
||||||
|
from logistics_chat.quote_data import import_snapshot
|
||||||
|
from logistics_chat.quote_engine import quantity, parse_intent
|
||||||
|
from test_quotes import fixture_snapshot, frames
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteBusinessTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||||
|
root=Path(folder.name)
|
||||||
|
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
|
||||||
|
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.client=self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session',json={'key':'1'*64})
|
||||||
|
book=fixture_snapshot()
|
||||||
|
documents=[
|
||||||
|
'铁铝制品另外加附加费¥5/KG',
|
||||||
|
'正常货物 A.0KG<单件重量<22KG B.0CM<最长单边长度<120CM C.0CM<第二边<75CM D.最长边<260CM',
|
||||||
|
'单件不能超22KG 超重收+70/件',
|
||||||
|
'普通超尺寸加收90RMB/件',
|
||||||
|
'大尺寸货物,任意2个条件,加收400RMB/件:22KG<单件重量<68KG;0<最长边<274CM;330CM<周长+单边长<419CM',
|
||||||
|
'超大件货物,加收USD900/件',
|
||||||
|
'保险费:保额的百分之0.25,min 13USD,投保金额是货值乘以1.2。',
|
||||||
|
'海外进口清关,超过5个品名,续页加收USD7元RMB/个',
|
||||||
|
'一般贸易出口报关费:RMB60/票',
|
||||||
|
'偏远地址加收4元/kg;超偏远地址加收6元/kg',
|
||||||
|
'禁止运输的货物:纯电池、液体、食品',
|
||||||
|
]
|
||||||
|
book['documents']=[{'sheet':'海派','cell':f'A{i+1}','text':text} for i,text in enumerate(documents)]
|
||||||
|
book['documents'].append({'sheet':'美森加班海卡','cell':'A1','text':'不可接一般贸易'})
|
||||||
|
with self.app.app_context():
|
||||||
|
c=db.connect();import_snapshot(c,book,activate=True);c.commit();c.close()
|
||||||
|
|
||||||
|
def ask(self,text,cid=None):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
events=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
|
||||||
|
model.assert_not_called()
|
||||||
|
self.assertEqual(events[-1],{'d':1},events)
|
||||||
|
return events[0]['t']
|
||||||
|
|
||||||
|
def test_different_cartons_use_individual_minimums_and_dimensions(self):
|
||||||
|
text=self.ask('美国美西,1箱10KG,1箱20KG,义乌普船海派')
|
||||||
|
self.assertIn('32KG = 64元',text)
|
||||||
|
text=self.ask('美国美西,2箱,1箱10KG,60x50x40cm,1箱20KG,40x30x20cm,义乌普船海派')
|
||||||
|
self.assertIn('40KG = 80元',text)
|
||||||
|
text=self.ask('美国美西,2箱,1箱10KG,60x50x40cm,1箱20KG,义乌普船海派')
|
||||||
|
self.assertIn('40KG = 80元',text)
|
||||||
|
self.assertIn('箱数与分箱明细不一致',self.ask('美国3箱,1箱10KG,1箱20KG海派'))
|
||||||
|
|
||||||
|
def test_weight_units_chinese_followup_and_invalid_postal(self):
|
||||||
|
cid=self.client.post('/api/new_chat').json['id']
|
||||||
|
self.ask('美国美西30KG,义乌海派',cid)
|
||||||
|
for text in ('五十公斤呢','50000克呢','0.05吨呢'):
|
||||||
|
self.assertIn('美国 50KG',self.ask(text,cid))
|
||||||
|
self.assertIn('大于 0',self.ask('美国重量为-30KG海派'))
|
||||||
|
self.assertIn('邮编格式不正确',self.ask('美国30KG,邮编518000海派'))
|
||||||
|
self.assertIn('美国 30KG',self.ask('美国30000克海派'))
|
||||||
|
self.assertIn('未找到目的仓码',self.ask('美国30KG发ABC999海派'))
|
||||||
|
|
||||||
|
def test_commodity_survives_weight_followups_and_new_commodity_replaces_it(self):
|
||||||
|
cid=self.client.post('/api/new_chat').json['id']
|
||||||
|
text=self.ask('美国美西铁制品30KG,义乌普船海派',cid)
|
||||||
|
self.assertIn('铁铝附加费5元/KG',text)
|
||||||
|
self.assertIn('30KG = 210元',text)
|
||||||
|
self.assertIn('50KG = 350元',self.ask('50KG呢',cid))
|
||||||
|
self.assertNotIn('铁铝附加费',self.ask('换成普通货50KG呢',cid))
|
||||||
|
|
||||||
|
def test_limit_fees_take_maximum_per_carton_and_do_not_guess_bad_threshold(self):
|
||||||
|
text=self.ask('美国美西1箱30KG,130x50x20cm,义乌普船海派')
|
||||||
|
self.assertIn('最高项90元/件',text)
|
||||||
|
self.assertNotIn('160元',text)
|
||||||
|
text=self.ask('美国美西1箱30KG,180x50x30cm,义乌普船海派')
|
||||||
|
self.assertIn('最高项400元/件',text)
|
||||||
|
text=self.ask('美国美西1箱30KG,40x30x20cm,义乌普船海派')
|
||||||
|
self.assertIn('最高项70元/件',text)
|
||||||
|
self.assertNotIn('400元/件',text)
|
||||||
|
self.assertIn('人工确认是否接收',self.ask('美国美西1箱70KG,40x30x20cm,义乌普船海派'))
|
||||||
|
|
||||||
|
def test_cbm_uses_two_decimals_half_up_without_creating_free_shipments(self):
|
||||||
|
rate={'unit':'cbm','kind':'truck','service':'洛杉矶普船海卡'}
|
||||||
|
intent=parse_intent('美国1KG,1.005CBM',set())
|
||||||
|
self.assertEqual(quantity(intent,rate),Decimal('1.01'))
|
||||||
|
intent['cbm']='1.004';self.assertEqual(quantity(intent,rate),Decimal('1.00'))
|
||||||
|
intent['weight']='0.1';intent['cbm']='0.004'
|
||||||
|
self.assertIsNone(quantity(intent,rate))
|
||||||
|
|
||||||
|
def test_business_topics_use_current_documents_instead_of_generic_quotes(self):
|
||||||
|
insurance=self.ask('美国保价1万美元,保险费怎么算')
|
||||||
|
self.assertIn('0.25%',insurance);self.assertIn('=25USD',insurance)
|
||||||
|
self.assertNotIn('元/KG',insurance)
|
||||||
|
self.assertIn('大于0',self.ask('美国保价-10美元,保险费怎么算'))
|
||||||
|
text=self.ask('美国30KG,7个品名清关续页收费')
|
||||||
|
self.assertIn('7美元/个',text);self.assertIn('=14美元',text)
|
||||||
|
text=self.ask('美国30KG还需要什么费用')
|
||||||
|
self.assertIn('偏远地址加4元/KG',text);self.assertIn('超偏远地址加6元/KG',text)
|
||||||
|
self.assertNotIn('250',text)
|
||||||
|
self.assertIn('不可接一般贸易',self.ask('美国美森加班海卡一般贸易报关多少钱'))
|
||||||
|
self.assertIn('不接收',self.ask('美国30KG纯电池可以发吗'))
|
||||||
|
self.assertIn('不接收',self.ask('美国30KG食品,FDA申报费多少人民币'))
|
||||||
|
|
||||||
|
def test_destination_origin_comparison_time_and_english(self):
|
||||||
|
cid=self.client.post('/api/new_chat').json['id']
|
||||||
|
self.ask('美国30KG发ZZZ1,义乌海派',cid)
|
||||||
|
text=self.ask('深圳交仓呢',cid)
|
||||||
|
self.assertIn('美国 30KG',text);self.assertNotIn('· 义乌',text)
|
||||||
|
text=self.ask('海运和空运哪个好,美国30KG')
|
||||||
|
self.assertIn('没有美国空运费率',text)
|
||||||
|
text=self.ask('美国美西海派多久')
|
||||||
|
self.assertIn('提取不等于送达',text);self.assertNotIn('元/KG',text)
|
||||||
|
text=self.ask('Please quote 30kg to US by sea')
|
||||||
|
self.assertIn('CNY/kg',text)
|
||||||
|
self.assertFalse(__import__('re').search(r'[\u4e00-\u9fff]',text),text)
|
||||||
|
|
||||||
|
def test_human_and_photo_questions_never_use_old_regional_model_facts(self):
|
||||||
|
self.assertIn('联系人工',self.ask('帮我转人工客服'))
|
||||||
|
text=self.ask('我可以发张货物照片让你判断报价吗')
|
||||||
|
self.assertIn('不会自动识别',text);self.assertNotIn('21KG',text)
|
||||||
|
|
||||||
|
def test_short_fee_synonyms_keep_current_us_rules(self):
|
||||||
|
cid=self.client.post('/api/new_chat').json['id']
|
||||||
|
self.ask('美国美西30KG,义乌普船海派',cid)
|
||||||
|
for question in ('还加什么费','还有其他费用吗','这个还要加钱吗','有其他额外收费吗'):
|
||||||
|
text=self.ask(question,cid)
|
||||||
|
self.assertIn('偏远地址加4元/KG',text)
|
||||||
|
self.assertNotIn('250',text)
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Synthetic prices exercise the quote contract; no customer workbook is committed."""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db
|
||||||
|
from logistics_chat.quote_data import import_snapshot, load_book
|
||||||
|
from logistics_chat.quote_engine import quantity
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_snapshot():
|
||||||
|
rates = []
|
||||||
|
for origin, extra in (('义乌',0), ('华南',1)):
|
||||||
|
for zone, price in (('美西',2), ('美中',3), ('美东',4)):
|
||||||
|
for minimum, discount in ((12,0),(51,1),(101,1.5)):
|
||||||
|
rates.append({'service':'普船海派','kind':'parcel','origin':origin,'zone':zone,
|
||||||
|
'unit':'kg','tax':'included','min_weight':str(minimum), 'price':str(price+extra-discount),
|
||||||
|
'eta':'船开后若干天','destinations':[],'source_sheet':'合成海派','source_cell':'C9'})
|
||||||
|
for unit, tax, price in (('kg','included','3'),('cbm','excluded','100')):
|
||||||
|
rates.append({'service':'合成海卡','kind':'truck','origin':origin,'zone':'','unit':unit,
|
||||||
|
'tax':tax,'min_weight':'0','price':price,'eta':'船开后若干天','destinations':['ZZZ1'],
|
||||||
|
'source_sheet':'合成海卡','source_cell':'C8'})
|
||||||
|
return {'schema':1,'source_name':'synthetic-quotes.xlsx','source_sha256':'1'*64,'rates':rates,
|
||||||
|
'warehouses':[{'code':'ZZZ1','zone':zone,'state':'AA','postal':'01234','address':'',
|
||||||
|
'source_sheet':'合成分区','source_cell':f'A{index+3}'} for index,zone in enumerate(('东1区','东2区'))],
|
||||||
|
'postcodes':[{'postal':'01234','severity':'remote','fee':'1'},
|
||||||
|
{'postal':'99901','severity':'extreme','fee':None},
|
||||||
|
{'postal':'12345','severity':'extreme','fee':None}],
|
||||||
|
'documents':[{'sheet':'合成说明','cell':'A1','text':'合成业务说明,不含真实数据'}]}
|
||||||
|
|
||||||
|
|
||||||
|
def frames(response):
|
||||||
|
return [json.loads(f[6:]) for f in response.get_data(as_text=True).split('\n\n') if f.startswith('data: ')]
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory(); self.addCleanup(directory.cleanup)
|
||||||
|
root = Path(directory.name)
|
||||||
|
self.app = create_app({'TESTING':True,'DATABASE':root/'history.db','UPLOAD_DIR':root/'uploads',
|
||||||
|
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session',json={'key':'1'*64})
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
import_snapshot(connection,fixture_snapshot(),activate=True)
|
||||||
|
connection.commit(); connection.close()
|
||||||
|
|
||||||
|
def ask(self, question, cid=None):
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':question,'conv_id':cid}))
|
||||||
|
self.assertEqual(events[-1],{'d':1},events)
|
||||||
|
self.assertFalse(any(e.get('e') for e in events),events)
|
||||||
|
return events[0]['t']
|
||||||
|
|
||||||
|
def conversation(self):
|
||||||
|
return self.client.post('/api/new_chat').json['id']
|
||||||
|
|
||||||
|
def test_guest_us_30kg_always_gets_prices_without_model_or_login(self):
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
for question in ('发送美国30KG','30kg发US仓海运报价','30kg发USFBA仓海运报价','30kg to US'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
text = self.ask(question)
|
||||||
|
self.assertIn('美西 2元/KG,30KG = 60元',text)
|
||||||
|
self.assertIn('美东 4元/KG,30KG = 120元',text)
|
||||||
|
self.assertIn('华南',text)
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_ambiguous_warehouse_returns_both_zones_and_own_rates(self):
|
||||||
|
text = self.ask('美国30kg发ZZZ1海派,义乌交仓')
|
||||||
|
for zone in ('东1区','东2区'):
|
||||||
|
self.assertIn('ZZZ1 · '+zone,text)
|
||||||
|
self.assertEqual(text.count('30KG = 150元'),2)
|
||||||
|
self.assertIn('海派已计入偏远附加费 1元/KG',text)
|
||||||
|
self.assertNotIn('华南',text)
|
||||||
|
|
||||||
|
def test_context_resumes_after_warehouse_origin_weight_and_dimensions(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
self.ask('美国30kg报价',cid)
|
||||||
|
text = self.ask('ZZZ1',cid)
|
||||||
|
self.assertIn('美国 30KG',text)
|
||||||
|
self.assertIn('合成海卡',text)
|
||||||
|
text = self.ask('华南呢',cid)
|
||||||
|
self.assertIn('30KG',text)
|
||||||
|
self.assertIn('华南',text)
|
||||||
|
self.assertNotIn('· 义乌',text)
|
||||||
|
text = self.ask('50kg呢',cid)
|
||||||
|
self.assertIn('美国 50KG',text)
|
||||||
|
text = self.ask('2箱每箱15kg,40x30x20cm呢',cid)
|
||||||
|
self.assertIn('美国 30KG',text)
|
||||||
|
fresh = self.ask('ZZZ1')
|
||||||
|
self.assertNotIn('美国 30KG',fresh)
|
||||||
|
self.assertIn('多少公斤',fresh)
|
||||||
|
|
||||||
|
def test_new_topics_do_not_reuse_old_quote_or_cross_order_privacy(self):
|
||||||
|
from logistics_chat.quote_service import answer_quote
|
||||||
|
cid = self.conversation()
|
||||||
|
self.ask('美国30kg报价',cid)
|
||||||
|
with self.app.test_request_context('/'):
|
||||||
|
self.assertIsNone(answer_quote('新的一票50kg发德国报价',cid))
|
||||||
|
db.save_message(cid,'user','美国货物丢失如何理赔?')
|
||||||
|
self.assertIsNone(answer_quote('50kg呢',cid))
|
||||||
|
events = frames(self.client.post('/stream_chat',json={'msg':'查询入仓码 INB20260001','conv_id':cid}))
|
||||||
|
self.assertTrue(events[0]['login_required'])
|
||||||
|
|
||||||
|
def test_replaced_destination_stays_replaced_after_other_followups(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
self.ask('美国30kg发ZZZ1海派,义乌',cid)
|
||||||
|
text = self.ask('换成邮编12345',cid)
|
||||||
|
self.assertNotIn('ZZZ1',text)
|
||||||
|
self.ask('那空运呢',cid)
|
||||||
|
text = self.ask('海运呢',cid)
|
||||||
|
self.assertNotIn('ZZZ1',text)
|
||||||
|
self.assertIn('12345',text)
|
||||||
|
self.assertIn('美国 30KG',text)
|
||||||
|
|
||||||
|
def test_new_quote_tables_start_empty_and_existing_orders_are_untouched(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
before = connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0]
|
||||||
|
import_snapshot(connection,fixture_snapshot(),activate=True)
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],before)
|
||||||
|
self.assertEqual(connection.execute("SELECT postal FROM quote_postcodes WHERE severity='remote'").fetchone()[0],'01234')
|
||||||
|
connection.execute('UPDATE quote_books SET active=0'); connection.commit(); connection.close()
|
||||||
|
text = self.ask('美国30kg海运报价')
|
||||||
|
self.assertIn('尚未包含',text)
|
||||||
|
|
||||||
|
def test_tier_boundaries_and_per_box_minimum_use_decimal_calculation(self):
|
||||||
|
for weight, amount in ((50,100),(51,51),(100,100),(101,50.5)):
|
||||||
|
with self.subTest(weight=weight):
|
||||||
|
self.assertIn(f'{amount:g}元',self.ask(f'美国美西{weight}kg海派,义乌交仓'))
|
||||||
|
text = self.ask('美国美西2箱每箱5kg,义乌海派')
|
||||||
|
self.assertIn('美国 10KG',text)
|
||||||
|
self.assertIn('24KG = 48元',text)
|
||||||
|
with self.app.app_context():
|
||||||
|
book = load_book()
|
||||||
|
rate = next(r for r in book['rates'] if r['kind']=='parcel')
|
||||||
|
intent = {'weight':'10','cbm':None,'dims':[60,50,40],'dim_unit':'cm','boxes':2}
|
||||||
|
self.assertEqual(quantity(intent,rate),Decimal(40))
|
||||||
|
|
||||||
|
def test_non_tax_volume_uses_density_rule_and_missing_volume_still_replies(self):
|
||||||
|
text = self.ask('美国30kg发ZZZ1,不包税,义乌')
|
||||||
|
self.assertIn('100元/CBM',text)
|
||||||
|
self.assertNotIn('30CBM',text)
|
||||||
|
text = self.ask('美国363kg发ZZZ1,不包税,0.5方,义乌')
|
||||||
|
self.assertIn('1CBM = 100元',text)
|
||||||
|
|
||||||
|
def test_extreme_remote_air_missing_and_invalid_inputs_finish_cleanly(self):
|
||||||
|
text = self.ask('美国30kg邮编12345海派,义乌')
|
||||||
|
self.assertIn('遥远地址',text)
|
||||||
|
self.assertIn('30KG = 120元',text)
|
||||||
|
self.assertIn('不接收',self.ask('美国30kg邮编99901海派'))
|
||||||
|
self.assertIn('空运费率当前资料没有列出',self.ask('美国30kg空运报价'))
|
||||||
|
self.assertIn('大于 0',self.ask('美国0kg海运报价'))
|
||||||
|
|
||||||
|
def test_quote_history_is_saved_and_isolated_by_visitor(self):
|
||||||
|
cid = self.conversation()
|
||||||
|
text = self.ask('美国30KG报价',cid)
|
||||||
|
history = self.client.get('/api/get_messages',query_string={'conv_id':cid}).json
|
||||||
|
self.assertTrue(any(m['content']==text for m in history))
|
||||||
|
other = self.app.test_client(); other.post('/api/visitor/session',json={'key':'2'*64})
|
||||||
|
self.assertEqual(other.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||||
|
|
||||||
|
def test_import_activation_is_fresh_idempotent_and_invalid_import_preserves_current(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
result = import_snapshot(connection,fixture_snapshot(),activate=True)
|
||||||
|
self.assertTrue(result['existing'])
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_rates').fetchone()[0],22)
|
||||||
|
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='2'*64
|
||||||
|
for rate in newer['rates']:
|
||||||
|
rate['price']='9'
|
||||||
|
import_snapshot(connection,newer,activate=True); connection.commit()
|
||||||
|
invalid = deepcopy(newer); invalid['source_sha256']='3'*64; invalid['rates'][0]['price']='NaN'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
import_snapshot(connection,invalid,activate=True)
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books WHERE active=1').fetchone()[0],1)
|
||||||
|
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'2'*64)
|
||||||
|
connection.close()
|
||||||
|
self.assertIn('美西 9元/KG,30KG = 270元',self.ask('美国30kg海派,义乌'))
|
||||||
|
|
||||||
|
def test_failed_insert_rolls_back_entire_version_and_import_does_not_commit_caller(self):
|
||||||
|
with self.app.app_context():
|
||||||
|
connection = db.connect()
|
||||||
|
broken = deepcopy(fixture_snapshot()); broken['source_sha256']='2'*64
|
||||||
|
broken['postcodes'].append(deepcopy(broken['postcodes'][0]))
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
import_snapshot(connection,broken,activate=True)
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM quote_books').fetchone()[0],1)
|
||||||
|
connection.rollback()
|
||||||
|
newer = deepcopy(fixture_snapshot()); newer['source_sha256']='3'*64
|
||||||
|
import_snapshot(connection,newer,activate=True)
|
||||||
|
connection.rollback()
|
||||||
|
self.assertEqual(connection.execute('SELECT source_sha256 FROM quote_books WHERE active=1').fetchone()[0],'1'*64)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_lowercase_english_pronoun_is_not_a_us_destination(self):
|
||||||
|
from logistics_chat.quote_service import answer_quote
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertIsNone(answer_quote('Can you help us quote 30kg to Germany?',None))
|
||||||
|
|
||||||
|
def test_local_xlsx_reader_ignores_external_formulas_and_fake_full_width_headers(self):
|
||||||
|
from logistics_chat.quote_workbook import read_sheets
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
path = Path(folder)/'fixture.xlsx'
|
||||||
|
ns = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
|
||||||
|
with ZipFile(path,'w') as archive:
|
||||||
|
archive.writestr('xl/workbook.xml',f'<workbook xmlns="{ns}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="合成资料" sheetId="1" r:id="rId1"/></sheets></workbook>')
|
||||||
|
archive.writestr('xl/_rels/workbook.xml.rels','<Relationships><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>')
|
||||||
|
archive.writestr('xl/worksheets/sheet1.xml',f'<worksheet xmlns="{ns}"><sheetData><row r="2"><c r="A2" t="inlineStr"><is><t>01234</t></is></c><c r="B2"><f>[1]Sheet!A1</f><v>999</v></c><c r="XFD2" t="inlineStr"><is><t>列16384</t></is></c></row></sheetData></worksheet>')
|
||||||
|
self.assertEqual(read_sheets(path),{'合成资料':{2:{'A':'01234'}}})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+30
-1
@@ -13,9 +13,19 @@ from logistics_chat import knowledge, pricing, tracking
|
|||||||
|
|
||||||
class RuleTests(unittest.TestCase):
|
class RuleTests(unittest.TestCase):
|
||||||
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
|
def test_specific_knowledge_wins_and_unknown_questions_have_no_match(self):
|
||||||
self.assertIn('22KG+', knowledge.search_knowledge('英国空运价格'))
|
for question in ('英国空运价格','英国空运多久','英国空运多少钱'):
|
||||||
|
self.assertIn('22KG+', knowledge.search_knowledge(question))
|
||||||
|
self.assertIn('21KG+', knowledge.search_knowledge('欧洲空运价格'))
|
||||||
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
self.assertIsNone(knowledge.search_knowledge('没有相关关键词的测试'))
|
||||||
|
|
||||||
|
def test_support_knowledge_matches_the_daily_china_time_service_window(self):
|
||||||
|
for question in ('人工客服工作时间', '周末人工客服上班吗', '怎么联系你们'):
|
||||||
|
with self.subTest(question=question):
|
||||||
|
answer = knowledge.search_knowledge(question)
|
||||||
|
self.assertIn('每天北京时间 8:00—22:00', answer)
|
||||||
|
self.assertNotIn('9:00', answer)
|
||||||
|
self.assertNotIn('不上班', answer)
|
||||||
|
|
||||||
def test_sea_tier_boundary_and_minimum(self):
|
def test_sea_tier_boundary_and_minimum(self):
|
||||||
self.assertEqual(pricing.calc_freight(100, 'sea')['price'], 12.3)
|
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(101, 'sea')['price'], 10.3)
|
||||||
@@ -40,6 +50,23 @@ class RuleTests(unittest.TestCase):
|
|||||||
self.assertEqual(intent['channel'], 'sea')
|
self.assertEqual(intent['channel'], 'sea')
|
||||||
self.assertEqual(pricing.parse_shipping_intent('1吨铁路')['weight'], 1000)
|
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('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):
|
def test_warehouse_country_and_surcharge(self):
|
||||||
result = pricing.calc_warehouse_card(2, '海运', country='DE')
|
result = pricing.calc_warehouse_card(2, '海运', country='DE')
|
||||||
@@ -51,6 +78,8 @@ class RuleTests(unittest.TestCase):
|
|||||||
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
|
self.assertIn('500', pricing.surcharge_reply(product, fee, 100))
|
||||||
self.assertEqual(pricing.check_island('07000')[1], 'IT')
|
self.assertEqual(pricing.check_island('07000')[1], 'IT')
|
||||||
self.assertIsNone(pricing.check_island('not a postcode'))
|
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):
|
def test_tracking_recognition_and_card_context(self):
|
||||||
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
|
self.assertEqual(tracking.extract_tracking_no('查询15252125'), '15252125')
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Short recordings are validated and sent only to the separately configured ASR."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from logistics_chat import create_app
|
||||||
|
from logistics_chat.speech_service import MAX_AUDIO_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechResponse:
|
||||||
|
def __init__(self, payload=None, status=200, raw=None):
|
||||||
|
self.status_code = status
|
||||||
|
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def iter_content(self, size):
|
||||||
|
yield self.raw
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class SpeechTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
self.root = Path(directory.name)
|
||||||
|
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
|
||||||
|
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
|
||||||
|
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
|
||||||
|
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
self.client.post('/api/visitor/session', json={'key':'1'*64})
|
||||||
|
|
||||||
|
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
|
||||||
|
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
|
||||||
|
'duration': duration, 'language':'zh', **extra})
|
||||||
|
self.addCleanup(response.close)
|
||||||
|
self.addCleanup(response.request.environ['wsgi.input'].close)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
|
||||||
|
self.app.config['ASR_API_KEY'] = ''
|
||||||
|
response = self.client.get('/api/speech/config')
|
||||||
|
self.assertFalse(response.json['enabled'])
|
||||||
|
self.assertIn('no-store', response.headers['Cache-Control'])
|
||||||
|
self.assertNotIn('key', response.get_data(as_text=True).lower())
|
||||||
|
with patch('logistics_chat.speech_service.requests.post') as post:
|
||||||
|
self.assertEqual(self.recording().status_code, 503)
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
|
||||||
|
upstream = SpeechResponse()
|
||||||
|
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
|
||||||
|
response = self.recording()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
|
||||||
|
url = post.call_args.args[0]
|
||||||
|
options = post.call_args.kwargs
|
||||||
|
self.assertEqual(url, self.app.config['ASR_URL'])
|
||||||
|
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
|
||||||
|
self.assertEqual(options['json']['model'], 'test-asr')
|
||||||
|
self.assertEqual(options['json']['asr_options']['language'], 'zh')
|
||||||
|
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
|
||||||
|
self.assertFalse(options['allow_redirects'])
|
||||||
|
self.assertTrue(upstream.closed)
|
||||||
|
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
|
||||||
|
self.assertEqual(self.client.get('/api/get_history').json, [])
|
||||||
|
|
||||||
|
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
|
||||||
|
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
|
||||||
|
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
|
||||||
|
for content, mime in samples:
|
||||||
|
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
|
||||||
|
self.assertEqual(self.recording(content, mime).status_code, 200)
|
||||||
|
|
||||||
|
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
|
||||||
|
with patch('logistics_chat.speech_service.requests.post') as post:
|
||||||
|
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
|
||||||
|
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
|
||||||
|
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
|
||||||
|
with self.subTest(duration=duration):
|
||||||
|
self.assertEqual(self.recording(duration=duration).status_code, 400)
|
||||||
|
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
|
||||||
|
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
|
||||||
|
self.assertEqual(response.status_code, 413)
|
||||||
|
self.assertFalse(response.json['ok'])
|
||||||
|
|
||||||
|
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
|
||||||
|
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
|
||||||
|
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
|
||||||
|
SpeechResponse(raw=b'x' * 65537)):
|
||||||
|
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
|
||||||
|
response = self.recording()
|
||||||
|
self.assertEqual(response.status_code, 502)
|
||||||
|
self.assertNotIn('secret', response.get_data(as_text=True))
|
||||||
|
self.assertTrue(upstream.closed)
|
||||||
|
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
|
||||||
|
self.assertEqual(self.recording().status_code, 504)
|
||||||
|
|
||||||
|
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
|
||||||
|
for text in ('', ' ', '字' * 2001):
|
||||||
|
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
|
||||||
|
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
|
||||||
|
self.assertEqual(self.recording().status_code, 422)
|
||||||
|
|
||||||
|
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
|
||||||
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
||||||
|
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
|
||||||
|
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
|
||||||
|
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
|
||||||
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||||
|
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
||||||
|
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
|
||||||
|
for _ in range(2):
|
||||||
|
response=self.recording()
|
||||||
|
self.assertEqual(response.status_code,200)
|
||||||
|
self.assertEqual(response.json['text'],'查询订单。')
|
||||||
|
self.assertEqual(post.call_count,3)
|
||||||
|
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
|
||||||
|
for call in post.call_args_list[1:]:
|
||||||
|
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
|
||||||
|
self.assertEqual(call.kwargs['json']['format'],'pcm')
|
||||||
|
self.assertEqual(call.kwargs['json']['rate'],16000)
|
||||||
|
self.assertEqual(call.kwargs['json']['channel'],1)
|
||||||
|
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
|
||||||
|
self.assertTrue(all(response.closed for response in responses))
|
||||||
|
|
||||||
|
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
|
||||||
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
|
||||||
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||||
|
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
|
||||||
|
patch('logistics_chat.speech_service.requests.post',side_effect=[
|
||||||
|
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
|
||||||
|
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
|
||||||
|
response=self.recording()
|
||||||
|
self.assertEqual(response.status_code,503)
|
||||||
|
self.assertNotIn('private',response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
|
||||||
|
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
|
||||||
|
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
|
||||||
|
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
|
||||||
|
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
|
||||||
|
patch('logistics_chat.speech_service.requests.post') as post:
|
||||||
|
for result,status in cases:
|
||||||
|
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
|
||||||
|
self.assertEqual(self.recording(duration='1').status_code,status)
|
||||||
|
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
|
||||||
|
self.assertEqual(self.recording().status_code,504)
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
|
||||||
|
from logistics_chat import speech_service
|
||||||
|
original=speech_service.transcribe_recording
|
||||||
|
streams=[]
|
||||||
|
def inspect(upload,*args):
|
||||||
|
self.assertIsInstance(upload.stream,io.BytesIO)
|
||||||
|
streams.append(upload.stream)
|
||||||
|
return original(upload,*args)
|
||||||
|
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
|
||||||
|
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
|
||||||
|
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
|
||||||
|
self.assertTrue(streams[0].closed)
|
||||||
@@ -6,6 +6,8 @@ from pathlib import Path
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from werkzeug.datastructures import FileStorage
|
from werkzeug.datastructures import FileStorage
|
||||||
@@ -29,6 +31,27 @@ class StorageTests(unittest.TestCase):
|
|||||||
self.addCleanup(self.context.pop)
|
self.addCleanup(self.context.pop)
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
||||||
|
def test_concurrent_startup_migrates_legacy_history_once_without_claiming_it(self):
|
||||||
|
connection = db.connect()
|
||||||
|
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
|
||||||
|
'(id INTEGER PRIMARY KEY, title TEXT NOT NULL, ctime TEXT NOT NULL);'
|
||||||
|
"INSERT INTO chat_history VALUES(1,'旧记录','now');")
|
||||||
|
connection.close()
|
||||||
|
gate = Barrier(4)
|
||||||
|
|
||||||
|
def initialize():
|
||||||
|
with self.app.app_context():
|
||||||
|
gate.wait(timeout=5)
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as workers:
|
||||||
|
for result in workers.map(lambda _: initialize(), range(4)):
|
||||||
|
self.assertIsNone(result)
|
||||||
|
connection = db.connect()
|
||||||
|
self.assertEqual(connection.execute('SELECT title, visitor_id FROM chat_history').fetchall(), [('旧记录', None)])
|
||||||
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM messages WHERE conv_id=1').fetchone()[0], 1)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
|
def test_old_schema_is_migrated_and_legacy_title_backfilled(self):
|
||||||
connection = db.connect()
|
connection = db.connect()
|
||||||
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
|
connection.executescript('DROP TABLE chat_history; CREATE TABLE chat_history '
|
||||||
@@ -100,6 +123,20 @@ class StorageTests(unittest.TestCase):
|
|||||||
self.assertNotIn('..', name)
|
self.assertNotIn('..', name)
|
||||||
self.assertFalse(result['is_image'])
|
self.assertFalse(result['is_image'])
|
||||||
|
|
||||||
|
def test_old_attachments_referenced_by_ai_or_human_history_survive_cleanup(self):
|
||||||
|
uploads=self.root/'uploads'; uploads.mkdir()
|
||||||
|
for name in ('ai.png','human.pdf','unused.txt'):
|
||||||
|
file=uploads/name;file.write_bytes(b'fixture')
|
||||||
|
os.utime(file,(time.time()-20*86400,time.time()-20*86400))
|
||||||
|
connection=db.connect()
|
||||||
|
connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(1,'user','[图片]/uploads/ai.png','now')")
|
||||||
|
connection.execute("INSERT INTO human_messages(session_id,side,content,ctime) VALUES(1,'customer','[文件]/uploads/human.pdf','now')")
|
||||||
|
connection.commit();connection.close()
|
||||||
|
file_service.cleanup_uploads()
|
||||||
|
self.assertTrue((uploads/'ai.png').exists())
|
||||||
|
self.assertTrue((uploads/'human.pdf').exists())
|
||||||
|
self.assertFalse((uploads/'unused.txt').exists())
|
||||||
|
|
||||||
def test_invalid_and_oversized_uploads_are_rejected(self):
|
def test_invalid_and_oversized_uploads_are_rejected(self):
|
||||||
self.assertEqual(file_service.upload_file({})[1], 400)
|
self.assertEqual(file_service.upload_file({})[1], 400)
|
||||||
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
|
denied = FileStorage(stream=io.BytesIO(b'bad'), filename='test.exe')
|
||||||
@@ -117,6 +154,18 @@ class StorageTests(unittest.TestCase):
|
|||||||
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
|
self.assertEqual(connection.execute('SELECT COUNT(*) FROM chat_history').fetchone()[0], 0)
|
||||||
connection.close()
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Independent guests cannot read, mutate or claim each other's chat data."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from logistics_chat import create_app, db
|
||||||
|
|
||||||
|
|
||||||
|
class VisitorTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
root = Path(directory.name)
|
||||||
|
self.app = create_app({'TESTING':True, 'DATABASE':root/'history.db', 'UPLOAD_DIR':root/'uploads',
|
||||||
|
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
|
||||||
|
self.a, self.b = self.app.test_client(), self.app.test_client()
|
||||||
|
for client, key in ((self.a,'1'*64),(self.b,'2'*64)):
|
||||||
|
self.assertEqual(client.post('/api/visitor/session',json={'key':key}).status_code,200)
|
||||||
|
|
||||||
|
def conversation(self, client, text):
|
||||||
|
cid = client.post('/api/new_chat').json['id']
|
||||||
|
with self.app.app_context(): db.save_message(cid,'user',text)
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def human(self, client, cid):
|
||||||
|
with patch('logistics_chat.human_service.datetime') as clock:
|
||||||
|
clock.now.return_value = datetime(2026,10,30,12)
|
||||||
|
return client.post('/api/visitor/human/request',json={'conv_id':cid}).json['session_id']
|
||||||
|
|
||||||
|
def test_entry_is_public_but_customer_apis_need_a_guest_session_without_basic_login(self):
|
||||||
|
fresh = self.app.test_client()
|
||||||
|
self.assertEqual(fresh.get('/').status_code,200)
|
||||||
|
response = fresh.get('/api/get_history')
|
||||||
|
self.assertEqual(response.status_code,401)
|
||||||
|
self.assertNotIn('WWW-Authenticate',response.headers)
|
||||||
|
self.assertEqual(fresh.post('/api/new_chat').status_code,401)
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||||
|
|
||||||
|
def test_guest_key_is_restorable_but_only_its_hash_is_stored(self):
|
||||||
|
cid = self.conversation(self.a,'A_PRIVATE')
|
||||||
|
restored = self.app.test_client()
|
||||||
|
response = restored.post('/api/visitor/session',json={'key':'1'*64},base_url='https://localhost')
|
||||||
|
for flag in ('HttpOnly','Secure','SameSite=Lax'):
|
||||||
|
self.assertIn(flag,response.headers['Set-Cookie'])
|
||||||
|
self.assertEqual(restored.get('/api/get_history').json[0]['id'],cid)
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
owner=connection.execute('SELECT visitor_id FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
|
||||||
|
connection.close()
|
||||||
|
self.assertEqual(len(owner),64)
|
||||||
|
self.assertNotEqual(owner,'1'*64)
|
||||||
|
|
||||||
|
def test_listing_loading_and_streaming_never_expose_another_guest(self):
|
||||||
|
a = self.conversation(self.a,'A_PRIVATE')
|
||||||
|
b = self.conversation(self.b,'B_PRIVATE')
|
||||||
|
self.assertEqual([row['id'] for row in self.a.get('/api/get_history').json],[a])
|
||||||
|
self.assertEqual([row['id'] for row in self.b.get('/api/get_history').json],[b])
|
||||||
|
self.assertEqual(self.a.get(f'/api/get_messages?conv_id={b}').json,[])
|
||||||
|
self.assertEqual(self.b.get(f'/api/get_messages?conv_id={a}').json,[])
|
||||||
|
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
response=self.a.post('/stream_chat',json={'msg':'继续上一段对话','conv_id':b})
|
||||||
|
self.assertIn('新建对话',response.get_data(as_text=True))
|
||||||
|
self.assertNotIn('B_PRIVATE',response.get_data(as_text=True))
|
||||||
|
self.assertEqual(len(self.b.get(f'/api/get_messages?conv_id={b}').json),2)
|
||||||
|
model.assert_not_called()
|
||||||
|
|
||||||
|
def test_delete_and_clear_only_affect_the_current_guest_and_keep_unowned_legacy_data(self):
|
||||||
|
a = self.conversation(self.a,'A_PRIVATE')
|
||||||
|
b = self.conversation(self.b,'B_PRIVATE')
|
||||||
|
sid_a,sid_b=self.human(self.a,a),self.human(self.b,b)
|
||||||
|
self.a.post('/api/visitor/human/send',json={'sid':sid_a,'content':'A_SUPPORT'})
|
||||||
|
self.b.post('/api/visitor/human/send',json={'sid':sid_b,'content':'B_SUPPORT'})
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
legacy=connection.execute("INSERT INTO chat_history(title,ctime) VALUES('LEGACY_PRIVATE','now')").lastrowid
|
||||||
|
connection.commit();connection.close()
|
||||||
|
self.assertEqual(self.a.delete(f'/api/del_history?id={b}').status_code,404)
|
||||||
|
self.assertTrue(self.a.delete('/api/clear_all').json['ok'])
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').json,[])
|
||||||
|
self.assertEqual(self.b.get('/api/get_history').json[0]['id'],b)
|
||||||
|
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid_b}').json['messages'][0]['content'],'B_SUPPORT')
|
||||||
|
with self.app.app_context():
|
||||||
|
connection=db.connect()
|
||||||
|
self.assertEqual(connection.execute('SELECT id FROM chat_history ORDER BY id').fetchall(),[(b,),(legacy,)])
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_customer_human_routes_enforce_owner_and_role_and_hide_internal_notes(self):
|
||||||
|
a=self.conversation(self.a,'A_PRIVATE')
|
||||||
|
sid=self.human(self.a,a)
|
||||||
|
self.assertEqual(self.b.post('/api/visitor/human/request',json={'conv_id':a}).status_code,404)
|
||||||
|
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
|
||||||
|
for action in ('send','close'):
|
||||||
|
self.assertEqual(self.b.post('/api/visitor/human/'+action,json={'sid':sid,'content':'attack'}).status_code,404)
|
||||||
|
self.assertEqual(self.a.post('/api/visitor/human/send',json={'sid':sid,'side':'agent','content':'fake agent'}).status_code,403)
|
||||||
|
self.a.post('/api/human/note',json={'sid':sid,'note':'STAFF_PRIVATE'})
|
||||||
|
response=self.a.get(f'/api/visitor/human/poll?sid={sid}')
|
||||||
|
self.assertNotIn('note',response.json)
|
||||||
|
self.assertNotIn('STAFF_PRIVATE',response.get_data(as_text=True))
|
||||||
|
self.assertEqual(self.b.post('/api/rate',json={'sid':sid,'stars':5}).status_code,404)
|
||||||
|
self.assertEqual(self.a.post('/api/rate',json=[1]).status_code,400)
|
||||||
|
self.assertEqual(self.a.post('/api/rate',json={'sid':sid,'stars':5}).status_code,200)
|
||||||
|
|
||||||
|
def test_uploads_are_private_and_legacy_files_are_not_assigned_to_new_visitors(self):
|
||||||
|
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'A_PRIVATE'),'note.txt')})
|
||||||
|
url=response.json['url']
|
||||||
|
with self.a.get(url) as downloaded:
|
||||||
|
self.assertEqual(downloaded.data,b'A_PRIVATE')
|
||||||
|
self.assertIn('no-store',downloaded.headers['Cache-Control'])
|
||||||
|
self.assertEqual(self.b.get(url).status_code,404)
|
||||||
|
self.assertEqual(self.app.test_client().get(url).status_code,404)
|
||||||
|
root=Path(self.app.config['UPLOAD_DIR'])
|
||||||
|
(root/'legacy.txt').write_text('LEGACY_PRIVATE')
|
||||||
|
self.assertEqual(self.a.get('/uploads/legacy.txt').status_code,404)
|
||||||
|
with self.a.get('/api/staff'+url) as staff:
|
||||||
|
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||||
|
|
||||||
|
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||||
|
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
|
||||||
|
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
|
||||||
|
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
|
||||||
|
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
|
||||||
|
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||||
|
lookup.assert_not_called();model.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_attachments_show_a_readable_private_page_without_disclosing_ownership(self):
|
||||||
|
response=self.a.post('/api/upload',data={'file':(io.BytesIO(b'fixture'),'missing.txt')})
|
||||||
|
url=response.json['url']
|
||||||
|
(Path(self.app.config['UPLOAD_DIR'])/url.rsplit('/',1)[1]).unlink()
|
||||||
|
own=self.a.get(url);other=self.b.get(url)
|
||||||
|
self.assertEqual(own.status_code,404)
|
||||||
|
self.assertIn('附件暂时无法访问',own.get_data(as_text=True))
|
||||||
|
self.assertIn('no-store',own.headers['Cache-Control'])
|
||||||
|
self.assertEqual(own.data,other.data)
|
||||||
|
self.assertNotIn(url,own.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_invalid_bootstrap_and_cross_site_requests_cannot_replace_the_guest(self):
|
||||||
|
cid=self.conversation(self.a,'A_PRIVATE')
|
||||||
|
for key in ('short','x'*64,'1'*65,[],{},123):
|
||||||
|
self.assertEqual(self.a.post('/api/visitor/session',json={'key':key}).status_code,400)
|
||||||
|
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'2'*64},headers={'Origin':'https://attacker.example'}).status_code,403)
|
||||||
|
self.assertEqual(self.a.post('/api/visitor/session',data={'key':'2'*64}).status_code,400)
|
||||||
|
self.assertEqual(self.a.post('/api/visitor/session',json={'key':'1'*64,'padding':'x'*2000}).status_code,413)
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').json[0]['id'],cid)
|
||||||
|
|
||||||
|
def test_owned_support_polling_is_not_counted_as_chat_generation_and_bounds_its_cursor(self):
|
||||||
|
cid=self.conversation(self.a,'A_PRIVATE')
|
||||||
|
sid=self.human(self.a,cid)
|
||||||
|
self.app.config['RATE_LIMIT_PER_MIN']=1
|
||||||
|
self.app.extensions['rate_hits'].clear()
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').status_code,200)
|
||||||
|
self.assertEqual(self.a.get('/api/get_history').status_code,429)
|
||||||
|
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}').status_code,200)
|
||||||
|
self.assertEqual(self.b.get(f'/api/visitor/human/poll?sid={sid}').status_code,404)
|
||||||
|
self.assertEqual(self.a.get(f'/api/visitor/human/poll?sid={sid}&after=9999999999999999999999999').status_code,400)
|
||||||
Reference in New Issue
Block a user