From 31a92f25f4c84f18a31eaf2451ccc4578797ee3e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 14:06:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=AF=B7=E6=B1=82=E9=99=90?= =?UTF-8?q?=E6=B5=81=E4=B8=BA=E6=AF=8F=E5=88=86=E9=92=9F600=E6=AC=A1?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 ++ README.md | 2 ++ logistics_chat/config.py | 9 ++++++++- tests/test_api.py | 9 +++++++++ tests/test_config.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 9603c6a..9c63723 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ # 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= +# Chat and supporting APIs each have a separate per-IP budget. Use a positive integer. +RATE_LIMIT_PER_MIN=600 # 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. diff --git a/README.md b/README.md index ee6ff2a..cab56d6 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ cp .env.example .env # 仅首次运行;已有 .env 时跳过 ### 配置说明 +请求限流默认每 IP 每分钟 600 次,聊天与辅助 API 分别计数。可在根目录 `.env` 中设置 `RATE_LIMIT_PER_MIN=600`,或通过进程、容器环境变量覆盖,取值须为正整数。配置在服务启动时读取,修改后重启服务。首页 GET/HEAD 独立于业务限流额度。 + 手机语音输入:点击输入区上方的麦克风按钮,再按住说话,松开后转文字并发送;上滑或取消触摸不发送,最长 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 配置。 diff --git a/logistics_chat/config.py b/logistics_chat/config.py index 6287c4a..9cbc94a 100644 --- a/logistics_chat/config.py +++ b/logistics_chat/config.py @@ -14,6 +14,13 @@ def default_config(): 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')) + rate_limit_value = os.environ.get('RATE_LIMIT_PER_MIN', local_config.get('RATE_LIMIT_PER_MIN', '600')) + try: + rate_limit_per_min = int(rate_limit_value) + except (TypeError, ValueError): + raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer') from None + if rate_limit_per_min < 1: + raise ValueError('RATE_LIMIT_PER_MIN must be a positive integer') return { 'DATABASE': ROOT_DIR / 'history.db', 'UPLOAD_DIR': ROOT_DIR / 'uploads', @@ -40,7 +47,7 @@ def default_config(): 'MAX_CONTEXT': 4, 'MAX_ANSWER_CHARS': 800, 'MAX_ANSWER_CHARS_EN': 1400, - 'RATE_LIMIT_PER_MIN': 60, + 'RATE_LIMIT_PER_MIN': rate_limit_per_min, 'ALLOWED_EXT': set(ALLOWED_EXT), 'MAX_UPLOAD_MB': MAX_UPLOAD_MB, 'HOST': '0.0.0.0', diff --git a/tests/test_api.py b/tests/test_api.py index 045ec34..98ca24d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -8,6 +8,7 @@ from pathlib import Path import subprocess import sys import tempfile +import time import unittest from unittest.mock import patch @@ -349,6 +350,14 @@ class ApiTests(unittest.TestCase): with self.client.get('/static/fa.jpg') as image: self.assertEqual(image.status_code, 200) + def test_600_request_budget_accepts_the_last_request_and_blocks_the_next(self): + self.app.config['RATE_LIMIT_PER_MIN'] = 600 + now = time.time() + self.app.extensions['rate_hits']['127.0.0.1'] = [now] * 599 + with patch('logistics_chat.hooks.time.time', return_value=now): + self.assertEqual(self.client.get('/api/get_history').status_code, 200) + self.assertEqual(self.client.get('/api/get_history').status_code, 429) + def test_attachment_roundtrip_and_rejection(self): response = self.client.post('/api/upload', data={'file': (io.BytesIO(b'hello'), 'hello.txt')}) self.assertEqual(response.status_code, 200) diff --git a/tests/test_config.py b/tests/test_config.py index 2df6ac3..72f2812 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,6 +21,7 @@ class ConfigurationTests(unittest.TestCase): environment.start() self.addCleanup(environment.stop) os.environ.pop('DEEPSEEK_API_KEY', None) + os.environ.pop('RATE_LIMIT_PER_MIN', None) def write_env(self, text): (self.root / '.env').write_text(text, encoding='utf-8') @@ -98,6 +99,36 @@ class ConfigurationTests(unittest.TestCase): os.environ['ORDER_DATA_TENANT_ID'] = 'tenant-environment' self.assertEqual(self.application().config['ORDER_DATA_TENANT_ID'], 'tenant-environment') + def test_rate_limit_defaults_to_600_requests_per_minute(self): + self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600) + + def test_rate_limit_can_be_configured_in_the_repository_env_file(self): + self.write_env('RATE_LIMIT_PER_MIN=" 1200 " # Local request budget\n') + self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 1200) + self.assertNotIn('RATE_LIMIT_PER_MIN', os.environ) + + def test_rate_limit_environment_and_factory_overrides_take_priority(self): + self.write_env('RATE_LIMIT_PER_MIN=1200\n') + os.environ['RATE_LIMIT_PER_MIN'] = '600' + self.assertEqual(self.application().config['RATE_LIMIT_PER_MIN'], 600) + self.assertEqual(self.application(RATE_LIMIT_PER_MIN=900).config['RATE_LIMIT_PER_MIN'], 900) + + def test_rate_limit_rejects_invalid_file_values(self): + for line in ('RATE_LIMIT_PER_MIN=', 'RATE_LIMIT_PER_MIN', 'RATE_LIMIT_PER_MIN=0', + 'RATE_LIMIT_PER_MIN=-1', 'RATE_LIMIT_PER_MIN=600.5', 'RATE_LIMIT_PER_MIN=invalid'): + with self.subTest(line=line): + self.write_env(line + '\n') + with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'): + self.application() + + def test_rate_limit_rejects_invalid_environment_values_over_a_valid_file(self): + self.write_env('RATE_LIMIT_PER_MIN=600\n') + for value in ('', '0', '-1', '600.5', 'invalid'): + with self.subTest(value=value): + os.environ['RATE_LIMIT_PER_MIN'] = value + with self.assertRaisesRegex(ValueError, 'RATE_LIMIT_PER_MIN must be a positive integer'): + self.application() + if __name__ == '__main__': unittest.main()