Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a92f25f4 | ||
|
|
98eae3641d |
@@ -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.
|
||||
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,11 +7,14 @@ from flask import current_app, jsonify, request
|
||||
|
||||
def rate_limit():
|
||||
path = request.path
|
||||
if request.endpoint == 'pages.index' and request.method in ('GET', 'HEAD'):
|
||||
return None
|
||||
if path in ('/healthz', '/api/visitor/human/poll') or path.startswith('/static') or path.startswith('/api/human/'):
|
||||
return None
|
||||
ip = request.remote_addr or 'unknown'
|
||||
now = time.time()
|
||||
hits = current_app.extensions['rate_hits'][ip]
|
||||
bucket = (ip, 'chat') if request.endpoint == 'chat.stream_chat' else ip
|
||||
hits = current_app.extensions['rate_hits'][bucket]
|
||||
while hits and now - hits[0] > 60:
|
||||
hits.pop(0)
|
||||
if len(hits) >= current_app.config['RATE_LIMIT_PER_MIN']:
|
||||
|
||||
+42
-1
@@ -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)
|
||||
@@ -359,12 +368,44 @@ class ApiTests(unittest.TestCase):
|
||||
|
||||
def test_health_check_remains_available_when_business_requests_are_rate_limited(self):
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1
|
||||
self.assertEqual(self.client.get('/').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||
response = self.client.get('/healthz')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {'status': 'ok'})
|
||||
|
||||
def test_homepage_does_not_consume_or_depend_on_business_rate_budget(self):
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1
|
||||
for method in ('get', 'head'):
|
||||
response = getattr(self.client, method)('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'text/html')
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||
for method in ('get', 'head'):
|
||||
response = getattr(self.client, method)('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'text/html')
|
||||
|
||||
def test_chat_and_supporting_requests_have_separate_limited_budgets(self):
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1
|
||||
response = self.client.post('/stream_chat', json=[])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'text/event-stream')
|
||||
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||
self.assertEqual(self.client.get('/stream_chat').status_code, 429)
|
||||
|
||||
def test_supporting_request_limit_does_not_block_chat(self):
|
||||
self.app.config['RATE_LIMIT_PER_MIN'] = 1
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 200)
|
||||
self.assertEqual(self.client.get('/api/get_history').status_code, 429)
|
||||
response = self.client.post('/stream_chat', json=[])
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.mimetype, 'text/event-stream')
|
||||
self.assertEqual(self.client.post('/stream_chat', json=[]).status_code, 429)
|
||||
|
||||
def test_feedback_stats_and_csv(self):
|
||||
cid = self.conversation()
|
||||
with self.app.app_context():
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user