支持从本地配置文件加载 DeepSeek 密钥

This commit is contained in:
2026-10-29 14:42:46 +08:00
parent 805675f8db
commit ab5b3580c1
9 changed files with 136 additions and 34 deletions
+4 -3
View File
@@ -1,4 +1,5 @@
# Reference only: am.py and start.bat do not automatically load .env files.
# PowerShell: $env:DEEPSEEK_API_KEY = "<your-key>"
# Linux/macOS: export DEEPSEEK_API_KEY="<your-key>"
# Copy this file to .env in the repository root and enter your DeepSeek API key.
# The application reads .env at startup. Restart after changing this file.
# The process environment takes precedence, including an explicitly empty value.
# Keep real credentials in .env; never put them in this example or source files.
DEEPSEEK_API_KEY=
+4 -4
View File
@@ -14,7 +14,7 @@
| Human support | `logistics_chat/human_service.py`; `logistics_chat/routes/human.py` |
| Business / files | `logistics_chat/business_service.py`, `logistics_chat/file_service.py`; `logistics_chat/routes/` |
| UI | `templates/`; `static/css/theme.css`: tokens; page CSS; `static/js/chat.js`: `doSend`, `I18N`; `static/js/admin.js`: `poll`, `sendReply`, `setChatView` |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`, `hooks.py`, `runtime.py` in that package |
| Startup / hooks | `logistics_chat/__init__.py`: `create_app`; `config.py`: `default_config`; `hooks.py`, `runtime.py` in that package |
| CLI / tests | `start.bat`: `probe_python`; `scripts/`, `tests/`, `examples/requests/` |
## Efficient Search & File Boundaries
@@ -35,12 +35,12 @@ Run from the repository root:
```powershell
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$env:DEEPSEEK_API_KEY = "<your-key>"
Copy-Item .env.example .env # First run only; set DEEPSEEK_API_KEY inside .env.
.\.venv\Scripts\python.exe am.py
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
```
Commands use project `.venv`. Optional `start.bat` prefers active `VIRTUAL_ENV`; verify selection with `start.bat --check-python`. No frontend build is required.
Optional `start.bat` prefers active `VIRTUAL_ENV`; verify selection with `start.bat --check-python`. No frontend build is required.
## Coding Style & Naming Conventions
@@ -58,4 +58,4 @@ History uses concise Chinese summaries. Keep commits focused. PRs explain behavi
## Security & Configuration Tips
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. `.env` is not loaded automatically. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Missing `agents/orders/tickets` initialization and complete admin authentication remain unresolved.
Never commit credentials or runtime/customer data. Revoke exposed keys. Keep `uploads/.gitkeep`. Startup reads the root `.env`; environment variables override it. Restart after changes. Imports are inert; `create_app` initializes runtime storage unless `INITIALIZE_RUNTIME=False`. Default HOST remains `0.0.0.0`. Missing `agents/orders/tickets` initialization and complete admin authentication remain unresolved.
+11 -7
View File
@@ -73,7 +73,8 @@
```powershell
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
$env:DEEPSEEK_API_KEY = "<your-key>"
Copy-Item .env.example .env # 仅首次运行;已有 .env 时跳过
# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动
.\.venv\Scripts\python.exe am.py
```
@@ -96,17 +97,20 @@ $env:DEEPSEEK_API_KEY = "<your-key>"
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
export DEEPSEEK_API_KEY="<your-key>"
cp .env.example .env # 仅首次运行;已有 .env 时跳过
# 编辑 .env,填写 DEEPSEEK_API_KEY 后再启动
.venv/bin/python am.py
```
### 配置说明
- [`.env.example`](.env.example) 仅列出配置格式,当前程序和启动脚本**不会自动读取 `.env`**;请在环境变量中配置密钥。
- PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程。双击启动脚本时,需要提前配置系统环境变量并重新打开窗口。
- 本地密钥放在项目根目录的 `.env` 中:首次运行复制 [`.env.example`](.env.example),填写 `DEEPSEEK_API_KEY=你的密钥`。`.env` 已被 Git 忽略,示例文件中的值应保持为空。
- 应用启动时自动读取根目录 `.env`,不依赖终端的当前目录;`am.py`、Windows 启动脚本和 WSGI 应用工厂均使用同一配置入口。修改密钥后须重启服务。
- 配置优先级为:显式传给 `create_app` 的配置 > 进程环境变量 > `.env` > 空值。环境变量即使为空也会覆盖 `.env`;本地填写后仍提示未配置时,请检查并移除旧的空环境变量。
- 部署环境仍可注入 `DEEPSEEK_API_KEY`。PowerShell 的 `$env:DEEPSEEK_API_KEY` 只影响当前终端及其子进程,双击启动脚本可直接使用 `.env`,无需配置系统环境变量。
- 密钥申请或更换可在 [DeepSeek 平台](https://platform.deepseek.com/api_keys) 完成;不要把密钥写入脚本、提交到 Git,或打印到日志。
- 当前服务监听 `0.0.0.0:5000`,局域网设备可能访问。仅供本机开发时,将 `logistics_chat/config.py` 中默认的 `HOST` 设置为 `"127.0.0.1"`。
- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥并通过环境变量配置新密钥。
- 历史启动脚本曾提交明文密钥。当前文件已移除该配置,但 Git 历史仍保留旧内容,应在平台作废旧密钥,并在 `.env` 或部署环境中配置新密钥。
## 目录结构
@@ -116,7 +120,7 @@ baiyue-logistics-chat/
├── rates_extra.py 扩展数据旧导入路径的兼容转发
├── logistics_chat/
│ ├── __init__.py create_app 应用工厂
│ ├── config.py 环境与绝对路径配置
│ ├── config.py .env、环境变量与绝对路径配置
│ ├── db.py SQLite、历史持久化、备份
│ ├── knowledge.py 知识与关键词检索
│ ├── pricing.py 运费解析与计算
@@ -164,7 +168,7 @@ baiyue-logistics-chat/
安装 Node.js 后,Python 测试还会运行 `tests/frontend/admin.test.cjs`,验证发送失败保留草稿、重复点击、切换会话、输入法及手机抽屉;也可单独执行 `node --test tests/frontend/admin.test.cjs`,无需安装 npm 依赖。未安装 Node.js 时该项明确跳过。
覆盖范围包括运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。
覆盖范围包括配置文件加载与优先级、运费边界与单位换算、知识检索、运单识别、旧 SQLite schema 迁移、事务回滚、附件、人工状态、历史、CSV、SSE 压缩/追问/异常/取消,以及 Windows 解释器选择。Windows 启动测试在其他系统上跳过,三个页面的外置资源也由接口测试检查。
模型请求使用模拟响应,数据库和附件使用临时目录;这不代表完成真实 DeepSeek 或真实物流接口联调。旧文档提及的原始 52 个业务测试和 29 个场景记录未随仓库提供,当前结果以实际测试输出为准。
+3 -2
View File
@@ -31,7 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`,再加载对应 `chat`、`admin`、`admin_login` 的 CSS/JS;保留全局事件入口 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 |
| 回归 | `tests/test_rules.py`、`test_storage.py`、`test_api.py`、`test_config.py`、`test_startup.py`、`test_maintenance_scripts.py`、`test_frontend.py`;`tests/frontend/admin.test.cjs` | 配置测试隔离 `.env` 和环境变量;数据、HTTP 与 SSE 使用隔离配置和模拟模型;Node 内置测试验证草稿、异步发送和服务资料抽屉;浏览器使用临时测试库 |
除标明的根目录文件外,表内缩写的 Python 文件均相对 `logistics_chat/`。
@@ -42,7 +42,7 @@ am.py # CLI;显式请求 am.app 时创建默认 WSGI
rates_extra.py # 旧导入路径的兼容转发
logistics_chat/
├── __init__.py # create_app 与显式运行时初始化
├── config.py # 环境变量、稳定根路径、服务/资源配置
├── config.py # 根目录 .env、环境变量、稳定根路径、服务/资源配置
├── db.py # 连接、schema、历史持久化、备份
├── knowledge.py # 知识与关键词检索
├── pricing.py # 运费规则、解析、换算、附加费说明
@@ -79,6 +79,7 @@ tests/ # 按业务模块组织的回归
- 所有模型 HTTP 经 `ai_client.post_completion`。保留流式问答 `(5, 90)`、压缩 `(5, 30)`、追问 `(5, 15)` 三组连接/读取超时;不能因共用客户端而合并期限。流结束或取消时释放 HTTP 响应。
- 模块导入不建库、不备份、不清理附件、不访问模型。`create_app` 默认显式初始化运行目录、数据库、备份与旧附件清理;`INITIALIZE_RUNTIME=False` 可禁止这些动作,此时调用方负责提供已有 schema。访问兼容入口 `am.app` 视为显式创建应用。
- `config.ROOT_DIR` 指向仓库根目录,模板/静态目录显式提供给 Flask。数据库、上传、备份、日志使用配置中的绝对路径,避免包文件移动改变数据位置。
- `default_config` 用 `python-dotenv` 读取根目录 `.env`,不修改 `os.environ`;只读取已支持的 `DEEPSEEK_API_KEY`,不展开 `${...}`。显式工厂配置优先于进程环境(含空值),再使用文件值。配置在应用创建时确定,修改后重启;真实 `.env` 不纳入 Git。
- 限流、输入状态由应用实例持有,不能每请求重置或跨测试应用共享。维护 CLI 和纯规则可用标准库加载,包入口延迟导入 Web 依赖。
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
+1 -1
View File
@@ -36,7 +36,7 @@ def create_chat_stream(msg, conv_id):
yield sse({'d': 1})
return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
if not get_setting('DEEPSEEK_API_KEY'):
return (sse({'e': '服务未配置 API Key,请设置环境变量 DEEPSEEK_API_KEY 后重启服务'}), {})
return (sse({'e': '服务未配置 API Key,请在 .env 或环境变量中设置 DEEPSEEK_API_KEY 后重启服务'}), {})
know_text = search_knowledge(msg)
ship = parse_shipping_intent(msg)
ship['dim_unit'] = detect_dim_unit(msg)
+6 -2
View File
@@ -1,4 +1,4 @@
"""Configuration defaults; importing this module does not initialize storage."""
"""Read local configuration without changing the process environment or storage."""
import os
from pathlib import Path
@@ -10,12 +10,16 @@ MAX_UPLOAD_MB = 10
def default_config():
from dotenv import dotenv_values
local_config = dotenv_values(ROOT_DIR / '.env', encoding='utf-8-sig', interpolate=False)
api_key = os.environ.get('DEEPSEEK_API_KEY', local_config.get('DEEPSEEK_API_KEY'))
return {
'DATABASE': ROOT_DIR / 'history.db',
'UPLOAD_DIR': ROOT_DIR / 'uploads',
'BACKUP_DIR': ROOT_DIR / 'backups',
'LOG_DIR': ROOT_DIR / 'logs',
'DEEPSEEK_API_KEY': os.environ.get('DEEPSEEK_API_KEY', '').strip(),
'DEEPSEEK_API_KEY': (api_key or '').strip(),
'MODEL_URL': 'https://api.deepseek.com/v1/chat/completions',
'MODEL_NAME': 'deepseek-chat',
'MAX_CONTEXT': 4,
BIN
View File
Binary file not shown.
+11 -15
View File
@@ -59,21 +59,8 @@ if not errorlevel 1 (
)
echo OK - port is free.
echo [2/3] Checking DEEPSEEK_API_KEY ...
if not defined DEEPSEEK_API_KEY (
echo.
echo [WARN] DEEPSEEK_API_KEY is empty in this window.
echo .env files are not loaded automatically.
echo If you just ran setx, reopen this window or restart VS Code.
echo setx DEEPSEEK_API_KEY "sk-xxxxxxxx"
echo.
pause
) else (
echo OK - key detected.
)
echo [3/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress" >nul 2>nul
echo [2/3] Checking dependencies ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import flask, requests, waitress, dotenv" >nul 2>nul
if errorlevel 1 (
echo Missing packages, installing ...
"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt
@@ -85,6 +72,15 @@ if errorlevel 1 (
)
echo OK - dependencies ready.
echo [3/3] Checking DeepSeek configuration ...
"%PYTHON_EXE%" %PYTHON_ARGS% -c "import sys; from logistics_chat.config import default_config; sys.exit(0 if default_config()['DEEPSEEK_API_KEY'] else 1)" >nul
if errorlevel 1 (
echo [WARN] No DeepSeek API key configured.
echo Copy .env.example to .env and set DEEPSEEK_API_KEY, then restart.
) else (
echo OK - key detected in .env or environment.
)
echo.
echo ============================================
echo Starting: http://127.0.0.1:5000
+96
View File
@@ -0,0 +1,96 @@
"""Configuration boundaries; credentials and model calls use synthetic fixtures."""
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch, sentinel
from logistics_chat import config, create_app
class ConfigurationTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
root_patch = patch.object(config, 'ROOT_DIR', self.root)
root_patch.start()
self.addCleanup(root_patch.stop)
environment = patch.dict(os.environ)
environment.start()
self.addCleanup(environment.stop)
os.environ.pop('DEEPSEEK_API_KEY', None)
def write_env(self, text):
(self.root / '.env').write_text(text, encoding='utf-8')
def application(self, **overrides):
return create_app({'TESTING': True, 'INITIALIZE_RUNTIME': False, **overrides})
def test_missing_file_leaves_ai_disabled_without_creating_files(self):
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
self.assertEqual(list(self.root.iterdir()), [])
def test_file_accepts_bom_quotes_comments_and_whitespace(self):
self.write_env('\ufeff# Local configuration\nexport DEEPSEEK_API_KEY=" file-key " # comment\n')
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'file-key')
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
def test_empty_or_unassigned_file_value_leaves_ai_disabled(self):
for line in ('DEEPSEEK_API_KEY=', 'DEEPSEEK_API_KEY', 'OTHER_SETTING=unused'):
with self.subTest(line=line):
self.write_env(line + '\n')
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], '')
def test_environment_overrides_file_including_an_explicit_empty_value(self):
self.write_env('DEEPSEEK_API_KEY=file-key\n')
for value, expected in ((' environment-key ', 'environment-key'), ('', '')):
with self.subTest(value=value):
os.environ['DEEPSEEK_API_KEY'] = value
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], expected)
def test_file_values_are_literal_and_do_not_expand_environment_variables(self):
os.environ['SYNTHETIC_CONFIG_VALUE'] = 'expanded'
self.write_env("DEEPSEEK_API_KEY='literal-${SYNTHETIC_CONFIG_VALUE}'\n")
self.assertEqual(config.default_config()['DEEPSEEK_API_KEY'], 'literal-${SYNTHETIC_CONFIG_VALUE}')
def test_file_is_resolved_from_repository_root_instead_of_working_directory(self):
self.write_env('DEEPSEEK_API_KEY=repository-key\n')
with tempfile.TemporaryDirectory() as directory:
(Path(directory) / '.env').write_text('DEEPSEEK_API_KEY=wrong-key\n', encoding='utf-8')
previous = Path.cwd()
try:
os.chdir(directory)
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'repository-key')
finally:
os.chdir(previous)
def test_explicit_factory_configuration_has_highest_priority(self):
self.write_env('DEEPSEEK_API_KEY=file-key\n')
os.environ['DEEPSEEK_API_KEY'] = 'environment-key'
for value in ('override-key', ''):
with self.subTest(value=value):
self.assertEqual(self.application(DEEPSEEK_API_KEY=value).config['DEEPSEEK_API_KEY'], value)
def test_new_factory_reads_updated_file_without_changing_an_existing_app(self):
self.write_env('DEEPSEEK_API_KEY=first-key\n')
first = self.application()
self.write_env('DEEPSEEK_API_KEY=second-key\n')
self.assertEqual(self.application().config['DEEPSEEK_API_KEY'], 'second-key')
self.assertEqual(first.config['DEEPSEEK_API_KEY'], 'first-key')
self.assertNotIn('DEEPSEEK_API_KEY', os.environ)
def test_model_client_sends_file_key_without_a_real_http_request(self):
from logistics_chat.ai_client import post_completion
self.write_env('DEEPSEEK_API_KEY=file-key\n')
app = self.application()
with app.app_context(), patch('logistics_chat.ai_client.requests.post', return_value=sentinel.response) as post:
response = post_completion({'model': 'deepseek-chat'}, timeout=(5, 90), stream=True)
self.assertIs(response, sentinel.response)
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer file-key')
if __name__ == '__main__':
unittest.main()