From 49fc75214f7e96b7e0332ff4b083d656f26cb9b0 Mon Sep 17 00:00:00 2001 From: z66 <1415243231@qq.com> Date: Fri, 14 Nov 2025 11:04:01 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E9=81=93=E4=BA=91V2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/fastapi_app.iml | 4 +- ARCHITECTURE.md | 225 ---------- BUG_REPORT.md | 506 --------------------- FASTAPI_LEARNING.md | 710 ----------------------------- FASTAPI_QUICK_REFERENCE.md | 496 --------------------- FASTAPI_README.md | 352 --------------- FLASK_TO_FASTAPI_MIGRATION.md | 620 -------------------------- README.md | 391 ++++++++++++++++ app/API_UPDATE.md | 186 -------- app/__init__.py | 2 - app/api.py | 793 --------------------------------- app/api/dependencies.py | 148 ++++++ app/api/routes.py | 171 +++++++ app/back_ground_tasks.py | 24 - app/config.py | 32 +- app/core/__init__.py | 21 +- app/core/header_manager.py | 118 ----- app/core/module_registry.py | 43 +- app/module/F6_Plugin_module.py | 167 ++++++- app/module/__init__.py | 8 + app/module/module.py | 97 +++- app/module/other_module.py | 32 +- app/schemas.py | 77 ++++ app/tasks/README.md | 89 ---- app/tasks/__init__.py | 15 +- app/tasks/bi_tasks.py | 86 ++++ app/tasks/brand_tasks.py | 30 +- app/tasks/common.py | 143 +++++- app/tasks/customer_tasks.py | 28 +- app/tasks/delete_tasks.py | 192 ++++---- app/utils/app_tools.py | 107 +++++ main.py | 255 +++++++---- utils/app_tools.py | 97 ---- 33 files changed, 1811 insertions(+), 4454 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 BUG_REPORT.md delete mode 100644 FASTAPI_LEARNING.md delete mode 100644 FASTAPI_QUICK_REFERENCE.md delete mode 100644 FASTAPI_README.md delete mode 100644 FLASK_TO_FASTAPI_MIGRATION.md create mode 100644 README.md delete mode 100644 app/API_UPDATE.md delete mode 100644 app/__init__.py delete mode 100644 app/api.py create mode 100644 app/api/dependencies.py create mode 100644 app/api/routes.py delete mode 100644 app/back_ground_tasks.py delete mode 100644 app/core/header_manager.py create mode 100644 app/schemas.py delete mode 100644 app/tasks/README.md create mode 100644 app/tasks/bi_tasks.py delete mode 100644 utils/app_tools.py diff --git a/.idea/fastapi_app.iml b/.idea/fastapi_app.iml index b340a3d..300e926 100644 --- a/.idea/fastapi_app.iml +++ b/.idea/fastapi_app.iml @@ -1,7 +1,9 @@ - + + + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 6c98e62..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,225 +0,0 @@ -# FastAPI项目架构说明文档 - -## 项目架构概览 - -本项目采用模块化、可扩展的架构设计,主要包含以下核心组件: - -``` -fastapi_app/ -├── app/ -│ ├── core/ # 核心模块(可选) -│ │ ├── header_manager.py # 请求头管理器 -│ │ ├── module_registry.py # 模块注册表 -│ │ └── __init__.py # 核心模块导出 -│ ├── module/ # 业务模块 -│ │ ├── F6_Plugin_module.py -│ │ ├── module.py -│ │ └── other_module.py -│ ├── tasks/ # 后台任务模块 -│ │ ├── common.py -│ │ ├── brand_tasks.py -│ │ ├── delete_tasks.py -│ │ └── customer_tasks.py -│ ├── utils/ # 工具模块 -│ │ └── app_tools.py -│ └── api.py # API接口 -├── main.py # FastAPI应用入口 -└── requirements_fastapi.txt # 依赖清单 -``` - -## 核心组件说明 - -### 1. 请求头管理器(HeaderManager) - -统一管理不同模块的请求头配置。 - -预定义配置: -- `default`: 默认请求头 -- `f6_login`: F6系统登录请求头 -- `jiandaoyun_api`: 简道云API请求头 - -使用示例: -```python -from app.core.header_manager import HeaderManager - -# 获取默认请求头 -headers = HeaderManager.get_headers() - -# 获取F6登录请求头 -headers = HeaderManager.get_headers('f6_login') - -# 自定义请求头 -headers = HeaderManager.get_headers( - 'default', - referer='https://example.com', - custom_headers={'X-Custom-Header': 'value'} -) - -# 注册新模块的请求头 -from app.core.header_manager import HeaderConfig -custom_config = HeaderConfig( - referer='https://new-module.com', - user_agent='Custom Agent' -) -HeaderManager.register_module_headers('new_module', custom_config) -``` - -### 2. 模块注册表(ModuleRegistry) - -统一注册和管理所有功能模块和操作(可选使用,主要用于请求头管理)。 - -注册操作: -```python -from app.core import core_manager - -core_manager.register_action( - action_name='my_action', - handler=my_handler_function, - module_name='my_module', - description='操作描述', - requires_auth=True, - header_module='default' -) -``` - -获取操作: -```python -action_config = core_manager.registry.get_action('my_action') -if action_config: - result = action_config.handler(data) -``` - -## 添加新功能模块 - -### 步骤1:创建模块文件 - -在 `app/module/` 目录下创建新模块文件,例如 `new_module.py`: - -```python -class NewModule: - def my_action(self, data): - """即刻执行的操作""" - # 处理逻辑 - return {'msg': '执行成功'} - - def my_background_action(self, data): - """后台执行的操作""" - # 这个函数会启动后台线程 - from app import back_ground_tasks - import threading - - thread = threading.Thread( - target=back_ground_tasks.my_background_task, - args=(data,) - ) - thread.start() - return {'msg': '正在执行中'} -``` - -### 步骤2:创建后台任务(如果需要) - -在 `app/tasks/` 目录下创建任务文件,例如 `new_tasks.py`: - -```python -from app.tasks.common import update_jiandaoyun, approve_workflow - -def my_background_task(data): - """后台任务函数""" - # 执行耗时操作 - result = "执行结果" - - # 更新简道云表单 - msg = update_jiandaoyun(data, result) - - # 提交工作流 - if msg.get('msg'): - approve_workflow(data) -``` - -### 步骤3:在 main.py 中注册操作 - -在 `main.py` 的 `get_action_map()` 函数中添加: - -```python -def get_action_map() -> dict: - # ... 现有代码 ... - new_module = NewModule() - return { - # ... 现有操作 ... - 'my_action': new_module.my_action, - 'my_background_action': new_module.my_background_action, - } -``` - -### 步骤4:注册新的请求头(如果需要) - -在 `app/core/header_manager.py` 中添加: - -```python -# 在 HeaderManager 类中添加 -NEW_MODULE_HEADERS = HeaderConfig( - referer='https://new-module.com', - user_agent='Custom User Agent', - custom_headers={'X-API-Key': 'your-api-key'} -) - -_module_headers: Dict[str, HeaderConfig] = { - # ... 现有配置 ... - 'new_module': NEW_MODULE_HEADERS, -} -``` - -## 执行方式说明 - -### 调度机制 - -项目使用原有的任务队列调度方式: - -1. **所有操作**都通过 `app_tools.enqueue_task()` 放入任务队列 -2. **同步等待**结果通过 `response_queue.get()` 获取 -3. **后台任务**在模块函数内部使用 `threading.Thread` 启动线程 - -### 即刻执行 vs 后台执行 - -- **即刻执行**:函数直接返回结果,通过任务队列同步执行 -- **后台执行**:函数内部启动线程执行耗时操作,立即返回"正在执行中"的提示 - -所有操作都通过统一的任务队列处理,后台任务由模块函数内部管理。 - -## API接口 - -### POST /webhook -统一处理所有请求的主接口。 - -请求头: -- `Action`: 操作名称 -- `Check`: (可选)F6_Plugin 操作时的检查标志 - -请求体: -```json -{ - "api_key": "...", - "entry_id": "...", - "data_id": "...", - "Action": "..." // F6_Plugin 操作时需要 -} -``` - -## 最佳实践 - -1. **模块职责单一**:每个模块只负责一类功能 -2. **统一注册**:所有操作都在 `main.py` 的 `get_action_map()` 中统一注册 -3. **请求头管理**:使用 HeaderManager 统一管理请求头,避免硬编码(可选) -4. **错误处理**:在模块函数中统一处理异常 -5. **日志记录**:使用统一的日志记录器 `logging.getLogger('app')` -6. **向后兼容**:保持 `app.back_ground_tasks` 的向后兼容性 -7. **任务队列**:所有操作都通过 `app_tools.enqueue_task()` 放入任务队列统一处理 - -## 后续扩展建议 - -1. **添加中间件**:用于请求验证、日志记录等 -2. **添加配置管理**:使用配置文件管理不同环境的设置 -3. **添加任务队列**:使用 Celery 或 RQ 管理后台任务 -4. **添加API文档**:使用 FastAPI 的自动文档功能 -5. **添加单元测试**:为每个模块添加测试用例 - diff --git a/BUG_REPORT.md b/BUG_REPORT.md deleted file mode 100644 index 81f21b2..0000000 --- a/BUG_REPORT.md +++ /dev/null @@ -1,506 +0,0 @@ -# Bug 报告 - -## 严重程度说明 -- 🔴 **严重**: 可能导致程序崩溃或数据丢失 -- 🟡 **中等**: 可能导致功能异常或错误处理不当 -- 🟢 **轻微**: 代码质量问题,不影响功能但需要改进 - ---- - -## 1. api.py (根目录) - 错误日志键名错误 - -### 位置 -- 第540行:`entry_data_batch_delete` 方法 -- 第654行:`workflow_task_hand_over` 方法 -- 第701行:`get_upload_token` 方法 -- 第745行:`upload_file` 方法 - -### 问题描述 -🟡 **中等** - 在错误日志中使用了错误的键名 `data['data_list']`,但这些方法中可能不存在该键。 - -### 代码示例 -```python -# 第540行 - entry_data_batch_delete -error_task_logger.error( - f"任务 {data['data_list'][start_index:end_index]} 连续{max_retries}次请求失败,放弃此次请求。") -# 应该是 data['data_ids'] - -# 第654行 - workflow_task_hand_over -error_task_logger.error( - f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。") -# 该方法没有 data_list 键 - -# 第701行 - get_upload_token -error_task_logger.error( - f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。") -# 该方法没有 data_list 键 - -# 第745行 - upload_file -error_task_logger.error( - f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。") -# 该方法没有 data_list 键 -``` - -### 修复建议 -使用正确的键名或使用 `data.get()` 方法安全访问。 - ---- - -## 2. main.py - Handler 调用问题 - -### 位置 -第76行和第80行 - -### 问题描述 -🟡 **中等** - 当 `action_map.get()` 返回默认的 lambda 函数时,handler 是一个函数对象,但代码中直接将其传递给 `enqueue_task`,这应该是正确的。但如果 handler 是 lambda,需要确保它能被正确调用。 - -### 代码示例 -```python -# 第76行 -handler = action_map.get(sub_action, lambda x: {'msg': '未执行'}) - -# 第80行 -handler = action_map.get(action, lambda x: {'msg': '未知的操作'}) -``` - -### 修复建议 -确保所有 handler 都能正确处理数据参数。如果 handler 可能返回 None,需要添加检查。 - ---- - -## 3. main.py - 队列阻塞风险 - -### 位置 -第86行 - -### 问题描述 -🟡 **中等** - 使用 `response_queue.get()` 会无限期阻塞,如果任务处理线程出现问题,可能导致请求永远挂起。 - -### 代码示例 -```python -result = await anyio.to_thread.run_sync(response_queue.get) -``` - -### 修复建议 -添加超时机制或使用 `response_queue.get(timeout=...)`。 - ---- - -## 4. app/api.py - 数组访问未检查 - -### 位置 -第712-713行:`get_upload_token` 方法 - -### 问题描述 -🔴 **严重** - 直接访问 `res_j['token_and_url_list'][0]` 可能导致 KeyError 或 IndexError。 - -### 代码示例 -```python -res_j = res.json() -upload_url = res_j['token_and_url_list'][0]['url'] -upload_token = res_j['token_and_url_list'][0]['token'] -``` - -### 修复建议 -添加检查: -```python -token_list = res_j.get('token_and_url_list', []) -if not token_list: - raise ValueError("未获取到上传凭证") -upload_url = token_list[0].get('url') -upload_token = token_list[0].get('token') -``` - ---- - -## 5. app/module/F6_Plugin_module.py - 数组访问未检查 - -### 位置 -第40行:`accept_file` 方法 - -### 问题描述 -🔴 **严重** - 直接访问 `data['data']['附件'][0]['url']` 可能导致 KeyError 或 IndexError。 - -### 代码示例 -```python -url = data['data']['附件'][0]['url'] -``` - -### 修复建议 -添加检查: -```python -attachments = data.get('data', {}).get('附件', []) -if not attachments: - return None, data -url = attachments[0].get('url') -``` - ---- - -## 6. app/module/F6_Plugin_module.py - 键访问未检查 - -### 位置 -第96行:`check_file` 方法 - -### 问题描述 -🔴 **严重** - 直接访问 `data1['data']['Action(隐藏)']` 可能导致 KeyError。 - -### 代码示例 -```python -action = data1['data']['Action(隐藏)'] -``` - -### 修复建议 -使用安全访问: -```python -action = data1.get('data', {}).get('Action(隐藏)') -if not action: - return {'msg': '缺少Action字段'} -``` - ---- - -## 7. app/module/module.py - 临时文件未清理 - -### 位置 -第21-31行:`get_captcha` 方法 - -### 问题描述 -🟢 **轻微** - 创建了临时文件 `captcha.png` 和 `preprocessed_captcha.png`,但没有清理。 - -### 代码示例 -```python -with open('captcha.png', 'wb') as f: - f.write(response.content) -# ... 处理文件 ... -image.save('preprocessed_captcha.png') -``` - -### 修复建议 -使用临时文件或处理完后删除: -```python -import tempfile -import os - -with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: - tmp.write(response.content) - tmp_path = tmp.name - -try: - # 处理文件 - ... -finally: - if os.path.exists(tmp_path): - os.remove(tmp_path) -``` - ---- - -## 8. app/module/module.py - group_id 可能为空 - -### 位置 -第65-73行:`login_in` 方法 - -### 问题描述 -🟡 **中等** - 如果找不到匹配的 `company_name`,`group_id` 为空字符串,后续请求可能失败。 - -### 代码示例 -```python -group_id = '' -for group in res_json.get('data', []): - if group["groupName"] == company_name: - group_id = group.get("groupId") - -token = quote(res_json['token']) -url = (f'https://yunxiu.f6car.cn/kzf6/user/loginAfterChooseGroup?' - f'token={token}&groupId={group_id}&macAddress=') -``` - -### 修复建议 -添加检查: -```python -if not group_id: - logger.error(f"未找到公司名称: {company_name}") - return None -``` - ---- - -## 9. app/module/module.py - 时间判断逻辑错误 - -### 位置 -第119行:`delete_customer_background` 方法(在 delete_tasks.py 中) -第246行:`delete_car_background` 方法(在 delete_tasks.py 中) - -### 问题描述 -🟡 **中等** - 时间判断逻辑错误:`if 20 <= now.hour <= 8:` 永远不会为 True(20 不可能小于等于 8)。 - -### 代码示例 -```python -now = datetime.now() -if 20 <= now.hour <= 8: - time.sleep(1) -else: - time.sleep(3) -``` - -### 修复建议 -应该是: -```python -now = datetime.now() -if 8 <= now.hour <= 20: # 8点到20点之间 - time.sleep(3.5) -else: # 其他时间 - time.sleep(1.5) -``` - ---- - -## 10. app/tasks/common.py - 数组访问未检查 - -### 位置 -第56行:`approve_workflow` 方法 - -### 问题描述 -🔴 **严重** - 直接访问 `json['tasks']` 和 `json['tasks'][0]` 可能导致 KeyError 或 IndexError。 - -### 代码示例 -```python -for task in json['tasks']: - if task['status'] == 0: - username = task['assignee']['username'] - instance_id = task['instance_id'] - task_id = task['task_id'] -``` - -### 修复建议 -添加检查: -```python -tasks = json.get('tasks', []) -if not tasks: - logger.error("未找到待处理任务") - return - -for task in tasks: - if task.get('status') == 0: - assignee = task.get('assignee', {}) - username = assignee.get('username') - instance_id = task.get('instance_id') - task_id = task.get('task_id') - if username and instance_id and task_id: - break -``` - ---- - -## 11. app/tasks/delete_tasks.py - 数组访问未检查 - -### 位置 -第62行:`delete_customer_background` 方法 -第154行:`delete_car_background` 方法 - -### 问题描述 -🔴 **严重** - 直接访问 `org_res.json().get("data").get("list")[0]` 可能导致 IndexError。 - -### 代码示例 -```python -operate_org_id = org_res.json().get("data").get("list")[0].get("orgId") -``` - -### 修复建议 -添加检查: -```python -org_data = org_res.json().get("data", {}).get("list", []) -if not org_data: - logger.error("未获取到门店信息") - return -operate_org_id = org_data[0].get("orgId") -``` - ---- - -## 12. app/tasks/customer_tasks.py - 重试逻辑错误 - -### 位置 -第52-67行:`modify_customer_info_background` 方法 - -### 问题描述 -🟡 **中等** - `retry_count` 在循环外部初始化,导致每次页面请求都使用相同的重试计数,而不是每页独立重试。 - -### 代码示例 -```python -retry_count = 0 -for page in range(1, total_pages + 1): - while retry_count < max_retries: - # ... - if response.status_code == 200: - break - else: - retry_count += 1 -``` - -### 修复建议 -将 `retry_count` 移到循环内部: -```python -for page in range(1, total_pages + 1): - retry_count = 0 - while retry_count < max_retries: - # ... -``` - ---- - -## 13. app/tasks/delete_tasks.py - 条件判断错误 - -### 位置 -第115-116行:`delete_customer_background` 方法 - -### 问题描述 -🟡 **中等** - `if success + fail < len(json_data):` 这个条件判断逻辑有问题,应该检查是否还有未处理的项。 - -### 代码示例 -```python -if success + fail < len(json_data): - continue -``` - -### 修复建议 -这个条件判断似乎是想检查是否处理完所有项,但逻辑不正确。应该移除或修正。 - ---- - -## 14. app/tasks/delete_tasks.py - 变量作用域问题 - -### 位置 -第215行:`delete_car_background` 方法 - -### 问题描述 -🟡 **中等** - 在循环外部使用了 `page` 变量,但 `page` 只在循环内部定义。 - -### 代码示例 -```python -for page in range(1, all_page + 1): - # ... - for item in items: - # ... - if not car_id or not customer_id: - logger.info(f"页码 {page} 中缺少必要的ID信息") # page 在这里可用 - # ... - # ... - logger.error(f"删除失败: 页码 {page}, ...") # page 在这里可用 -``` - -实际上这个不是bug,`page` 在循环内部是可用的。但如果在循环外部使用就会有问题。 - ---- - -## 15. api.py (根目录) - 文件资源泄漏 - -### 位置 -第721-747行:`upload_file` 方法 - -### 问题描述 -🔴 **严重** - 文件在循环外部打开,如果循环中发生异常或提前返回,文件可能不会被关闭,导致资源泄漏。 - -### 代码示例 -```python -f = open(file_path, 'rb') -files = {"file": f} - -retries = 0 -while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10) - # ... - if res.status_code == 200: - return data_get # 提前返回,文件未关闭! - # ... - except requests.exceptions.RequestException as e: - # 如果异常发生,文件可能不会被关闭 - # ... -f.close() # 只有在循环结束后才会执行 -``` - -### 修复建议 -使用 `with` 语句确保文件总是被关闭: -```python -retries = 0 -result = None -while retries <= max_retries: - try: - with open(file_path, 'rb') as f: - files = {"file": f} - res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10) - res.raise_for_status() - data_get = res.json() - if res.status_code == 200: - return data_get - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - -if retries > max_retries: - error_task_logger.error(f"上传文件失败,已重试{max_retries}次") -return None -``` - ---- - -## 16. api.py (根目录) - 重试时文件指针问题 - -### 位置 -第721-747行:`upload_file` 方法 - -### 问题描述 -🟡 **中等** - 文件在循环外部打开,重试时文件指针已经移动到文件末尾,后续重试会读取空内容。 - -### 代码示例 -```python -f = open(file_path, 'rb') -files = {"file": f} - -retries = 0 -while retries <= max_retries: - res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10) - # 第一次请求后,文件指针已经移动到末尾 - # 重试时会读取空内容 -``` - -### 修复建议 -每次重试时重新打开文件,或使用 `with` 语句在每次循环中打开文件。 - ---- - -## 总结 - -### 按严重程度分类 - -**严重 (🔴)** - 需要立即修复: -1. app/api.py - 数组访问未检查(get_upload_token) -2. app/module/F6_Plugin_module.py - 数组访问未检查(accept_file) -3. app/module/F6_Plugin_module.py - 键访问未检查(check_file) -4. app/tasks/common.py - 数组访问未检查(approve_workflow) -5. app/tasks/delete_tasks.py - 数组访问未检查(两处) -6. api.py (根目录) - 文件资源泄漏(upload_file) - -**中等 (🟡)** - 建议尽快修复: -1. api.py - 错误日志键名错误(多处) -2. api.py - 重试时文件指针问题(upload_file) -3. main.py - Handler 调用和队列阻塞问题 -4. app/module/module.py - group_id 可能为空 -5. app/module/module.py - 时间判断逻辑错误(两处) -6. app/tasks/customer_tasks.py - 重试逻辑错误 -7. app/tasks/delete_tasks.py - 条件判断错误 - -**轻微 (🟢)** - 代码质量改进: -1. app/module/module.py - 临时文件未清理 - -### 建议的修复优先级 - -1. **立即修复**:所有严重级别的bug,特别是可能导致程序崩溃的数组访问问题 -2. **尽快修复**:中等级别的bug,特别是逻辑错误和错误处理问题 -3. **代码改进**:轻微级别的bug,在时间允许时进行改进 - diff --git a/FASTAPI_LEARNING.md b/FASTAPI_LEARNING.md deleted file mode 100644 index 4f93bec..0000000 --- a/FASTAPI_LEARNING.md +++ /dev/null @@ -1,710 +0,0 @@ -# FastAPI 学习文档 - -## 📚 目录 - -1. [FastAPI 简介](#fastapi-简介) -2. [Flask vs FastAPI 对比](#flask-vs-fastapi-对比) -3. [FastAPI 核心概念](#fastapi-核心概念) -4. [项目中的 FastAPI 使用](#项目中的-fastapi-使用) -5. [学习路径建议](#学习路径建议) -6. [常见问题解答](#常见问题解答) - ---- - -## FastAPI 简介 - -### 什么是 FastAPI? - -FastAPI 是一个现代、快速(高性能)的 Web 框架,用于构建 API,基于 Python 3.6+ 的类型提示(type hints)。 - -### 主要特点 - -1. **高性能**:与 NodeJS 和 Go 相当,是最快的 Python 框架之一 -2. **快速开发**:开发速度提升约 200% 到 300% -3. **自动文档**:自动生成交互式 API 文档(Swagger UI) -4. **类型提示**:基于 Python 类型提示,提供更好的 IDE 支持 -5. **异步支持**:原生支持 async/await -6. **数据验证**:自动进行请求和响应数据验证 - ---- - -## Flask vs FastAPI 对比 - -### 1. 基本语法对比 - -#### Flask 写法 -```python -from flask import Flask, request, jsonify - -app = Flask(__name__) - -@app.route('/webhook', methods=['POST']) -def webhook(): - data = request.get_json() - return jsonify({'msg': 'success'}) -``` - -#### FastAPI 写法 -```python -from fastapi import FastAPI -from fastapi.responses import JSONResponse -from pydantic import BaseModel - -app = FastAPI() - -class RequestData(BaseModel): - name: str - age: int - -@app.post("/webhook") -async def webhook(data: RequestData): - return JSONResponse({'msg': 'success'}) -``` - -### 2. 主要区别 - -| 特性 | Flask | FastAPI | -|------|-------|---------| -| **异步支持** | 需要额外库(Flask-AsyncIO) | 原生支持 async/await | -| **类型验证** | 手动验证 | 自动验证(基于 Pydantic) | -| **API 文档** | 需要手动编写 | 自动生成(Swagger/OpenAPI) | -| **性能** | 中等 | 高性能(接近 NodeJS) | -| **依赖注入** | 需要额外库 | 内置支持 | -| **数据验证** | 手动处理 | 自动验证和转换 | - -### 3. 请求处理对比 - -#### Flask -```python -from flask import request - -@app.route('/api', methods=['POST']) -def api(): - # 手动获取 JSON 数据 - data = request.get_json() - # 手动验证 - if not data or 'name' not in data: - return jsonify({'error': '缺少 name 字段'}), 400 - return jsonify({'result': data['name']}) -``` - -#### FastAPI -```python -from pydantic import BaseModel - -class Item(BaseModel): - name: str - age: int - -@app.post("/api") -async def api(item: Item): - # 自动验证,如果 name 缺失会自动返回 422 错误 - return {'result': item.name} -``` - ---- - -## FastAPI 核心概念 - -### 1. 应用实例(FastAPI App) - -```python -from fastapi import FastAPI - -# 创建 FastAPI 应用实例 -app = FastAPI( - title="简道云FastAPI服务", # API 文档标题 - description="简道云 API 服务", # API 描述 - version="1.0.0" # 版本号 -) -``` - -**项目中的使用**(`main.py`): -```python -app = FastAPI(title="简道云FastAPI服务") -``` - -### 2. 路由装饰器(Route Decorators) - -FastAPI 使用装饰器定义路由,类似于 Flask: - -```python -@app.get("/") # GET 请求 -@app.post("/") # POST 请求 -@app.put("/") # PUT 请求 -@app.delete("/") # DELETE 请求 -``` - -**项目中的使用**(`main.py`): -```python -@app.post("/webhook") -async def webhook(request: Request): - # 处理逻辑 - pass -``` - -### 3. 请求对象(Request) - -FastAPI 的 `Request` 对象提供了访问请求信息的方法: - -```python -from fastapi import Request - -@app.post("/webhook") -async def webhook(request: Request): - # 获取 JSON 数据 - data = await request.json() - - # 获取请求头 - headers = request.headers - - # 获取查询参数 - query_params = request.query_params - - # 获取路径参数 - path_params = request.path_params -``` - -**项目中的使用**(`main.py`): -```python -@app.post("/webhook") -async def webhook(request: Request): - # 获取请求数据 - data = await request.json() - header = request.headers - - # 解码请求头 - decoded_header = app_tools.decode_headers(header) -``` - -### 4. 响应对象(Response) - -FastAPI 提供了多种响应类型: - -```python -from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse - -@app.post("/api") -async def api(): - # JSON 响应 - return JSONResponse({'msg': 'success'}) - - # 或者直接返回字典(FastAPI 会自动转换为 JSON) - return {'msg': 'success'} -``` - -**项目中的使用**(`main.py`): -```python -return JSONResponse(result) -``` - -### 5. 异步函数(Async Functions) - -FastAPI 支持异步函数,使用 `async` 和 `await`: - -```python -@app.post("/webhook") -async def webhook(request: Request): - # 异步获取 JSON 数据 - data = await request.json() - - # 异步调用其他函数 - result = await some_async_function(data) - - return result -``` - -**项目中的使用**(`main.py`): -```python -@app.post("/webhook") -async def webhook(request: Request): - data = await request.json() # 异步获取数据 - # ... - result = await anyio.to_thread.run_sync(response_queue.get) # 异步执行同步函数 -``` - -### 6. 生命周期事件(Lifecycle Events) - -FastAPI 提供了应用启动和关闭事件: - -```python -@app.on_event("startup") -async def startup_event(): - """应用启动时执行""" - print("应用启动") - -@app.on_event("shutdown") -async def shutdown_event(): - """应用关闭时执行""" - print("应用关闭") -``` - -**项目中的使用**(`main.py`): -```python -@app.on_event("startup") -def on_startup(): - """应用启动时初始化""" - app.state.app_tools = AppTools(Config) - app.state.logger = setup_global_logger(Config) - app.state.f6_module = F6Module() - app.state.f6_plugin_module = F6PluginModule() - app.state.other_module = OtherPluginModule() -``` - -### 7. 应用状态(Application State) - -FastAPI 允许在应用实例上存储状态: - -```python -# 设置状态 -app.state.my_data = "some value" - -# 获取状态 -my_data = app.state.my_data -``` - -**项目中的使用**(`main.py`): -```python -# 在启动时设置 -app.state.app_tools = AppTools(Config) -app.state.logger = setup_global_logger(Config) - -# 在路由中使用 -logger = app.state.logger -app_tools: AppTools = app.state.app_tools -``` - -### 8. 数据验证(Pydantic Models) - -FastAPI 使用 Pydantic 进行数据验证: - -```python -from pydantic import BaseModel - -class User(BaseModel): - name: str - age: int - email: str = None # 可选字段 - -@app.post("/users") -async def create_user(user: User): - # user 已经自动验证和转换 - return {"name": user.name, "age": user.age} -``` - -**项目中的潜在使用**(可以改进): -```python -# 可以定义请求模型 -class WebhookRequest(BaseModel): - api_key: str - entry_id: str - data_id: str - Action: str = None # 可选字段 - -@app.post("/webhook") -async def webhook(request: Request, data: WebhookRequest): - # data 已经自动验证 - pass -``` - ---- - -## 项目中的 FastAPI 使用 - -### 1. 项目结构分析 - -``` -fastapi_app/ -├── main.py # FastAPI 应用入口 -├── api.py # API 工具类(简道云 API 封装) -├── app/ -│ ├── api.py # API 模块(简道云 API 封装) -│ ├── config.py # 配置管理 -│ ├── module/ # 业务模块 -│ ├── tasks/ # 后台任务 -│ └── utils/ # 工具函数 -└── requirements.txt # 依赖列表 -``` - -### 2. main.py 详解 - -让我们逐步分析 `main.py` 文件: - -#### 2.1 导入和初始化 - -```python -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse -import json -import anyio -from app.utils.app_tools import AppTools, setup_global_logger -from app.module.F6_Plugin_module import F6PluginModule -from app.module.module import F6Module -from app.module.other_module import OtherPluginModule -from app.config import Config - -# 创建 FastAPI 应用实例 -app = FastAPI(title="简道云FastAPI服务") -``` - -**说明**: -- `FastAPI`:主应用类 -- `Request`:请求对象,用于访问请求信息 -- `JSONResponse`:JSON 响应类 - -#### 2.2 启动事件 - -```python -@app.on_event("startup") -def on_startup(): - """应用启动时初始化""" - app.state.app_tools = AppTools(Config) - app.state.logger = setup_global_logger(Config) - app.state.f6_module = F6Module() - app.state.f6_plugin_module = F6PluginModule() - app.state.other_module = OtherPluginModule() -``` - -**说明**: -- `@app.on_event("startup")`:应用启动时执行 -- `app.state`:存储应用级别的状态 -- 初始化工具类、日志记录器和业务模块 - -#### 2.3 路由处理 - -```python -@app.post("/webhook") -async def webhook(request: Request): - """ - 接受前端请求后将任务放入消息队列 - - Returns: - any: 返回任务处理的结果 - """ - logger = app.state.logger - app_tools: AppTools = app.state.app_tools - - # 获取请求数据 - data = await request.json() - header = request.headers - - # 解码请求头 - decoded_header = app_tools.decode_headers(header) - - # 获取操作映射表 - action_map = get_action_map() - action = decoded_header.get('Action') - - # 处理逻辑... - - # 将任务放入消息队列 - response_queue = app_tools.enqueue_task(handler, data) - - # 等待任务处理结果 - result = await anyio.to_thread.run_sync(response_queue.get) - - logger.info(json.dumps(result, ensure_ascii=False, indent=4)) - - return JSONResponse(result) -``` - -**关键点**: -1. `async def`:异步函数定义 -2. `await request.json()`:异步获取 JSON 数据 -3. `await anyio.to_thread.run_sync()`:在线程池中执行同步函数 -4. `JSONResponse`:返回 JSON 响应 - -### 3. 任务队列机制 - -项目使用自定义的任务队列来处理请求: - -```python -# 在 app_tools.py 中 -class AppTools: - def __init__(self, config): - self.task_queue = Queue() - self._start_task_thread() - - def enqueue_task(self, handler, data): - response_queue = Queue() - self.task_queue.put({ - 'handler': handler, - 'data': data, - 'response': response_queue - }) - return response_queue -``` - -**工作流程**: -1. 请求到达 `/webhook` 端点 -2. 将任务放入队列 -3. 后台线程处理任务 -4. 等待结果返回 - -### 4. 操作映射机制 - -项目使用操作映射表来路由不同的操作: - -```python -def get_action_map() -> dict: - """获取操作映射表""" - f6_module = app.state.f6_module - f6_plugin_module = app.state.f6_plugin_module - other_module = app.state.other_module - return { - 'login_in': f6_module.accept_login_message, - 'get_company_information': f6_module.get_company_information, - 'create_brand': f6_plugin_module.create_brand, - # ... 更多操作 - } -``` - -**说明**: -- 通过请求头中的 `Action` 字段确定要执行的操作 -- 使用字典映射操作名到处理函数 - ---- - -## 学习路径建议 - -### 阶段 1:基础理解(1-2 天) - -1. **理解 FastAPI 基本概念** - - 阅读官方文档:https://fastapi.tiangolo.com/ - - 理解路由、请求、响应的基本用法 - -2. **运行项目** - ```bash - # 安装依赖 - pip install -r requirements.txt - - # 运行项目 - python main.py - # 或 - uvicorn main:app --reload - ``` - -3. **访问 API 文档** - - 启动后访问:http://localhost:5003/docs - - 查看自动生成的 Swagger UI 文档 - -### 阶段 2:代码分析(2-3 天) - -1. **分析 main.py** - - 理解应用初始化流程 - - 理解路由处理逻辑 - - 理解任务队列机制 - -2. **分析业务模块** - - 查看 `app/module/` 目录下的模块 - - 理解操作映射机制 - - 理解模块间的交互 - -3. **分析工具类** - - 查看 `app/utils/app_tools.py` - - 理解任务队列实现 - - 理解日志记录机制 - -### 阶段 3:实践练习(3-5 天) - -1. **添加新路由** - ```python - @app.get("/health") - async def health_check(): - return {"status": "ok"} - ``` - -2. **添加数据验证** - ```python - from pydantic import BaseModel - - class WebhookData(BaseModel): - api_key: str - entry_id: str - data_id: str - - @app.post("/webhook") - async def webhook(data: WebhookData): - return {"received": data.dict()} - ``` - -3. **添加错误处理** - ```python - from fastapi import HTTPException - - @app.post("/webhook") - async def webhook(request: Request): - try: - data = await request.json() - # 处理逻辑 - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - ``` - -### 阶段 4:深入学习(1-2 周) - -1. **学习异步编程** - - 理解 `async/await` - - 理解 `asyncio` - - 理解并发处理 - -2. **学习 Pydantic** - - 数据验证 - - 数据序列化 - - 自定义验证器 - -3. **学习依赖注入** - - FastAPI 的依赖注入系统 - - 数据库连接管理 - - 认证和授权 - ---- - -## 常见问题解答 - -### Q1: FastAPI 和 Flask 的主要区别是什么? - -**A:** 主要区别: -1. **性能**:FastAPI 性能更高,支持异步 -2. **类型验证**:FastAPI 自动验证,Flask 需要手动 -3. **API 文档**:FastAPI 自动生成,Flask 需要手动编写 -4. **异步支持**:FastAPI 原生支持,Flask 需要额外库 - -### Q2: 为什么使用 `async def` 而不是 `def`? - -**A:** -- `async def` 允许使用 `await` 关键字 -- 可以异步处理 I/O 操作(如网络请求、数据库查询) -- 提高并发性能 - -**示例**: -```python -# 同步(阻塞) -def sync_function(): - data = requests.get("https://api.example.com") # 阻塞 - return data - -# 异步(非阻塞) -async def async_function(): - async with httpx.AsyncClient() as client: - data = await client.get("https://api.example.com") # 非阻塞 - return data -``` - -### Q3: `app.state` 是什么?为什么要用它? - -**A:** -- `app.state` 是 FastAPI 提供的应用级状态存储 -- 用于存储需要在多个路由之间共享的数据 -- 类似于 Flask 的 `g` 对象,但作用域是整个应用 - -**项目中的使用**: -```python -# 启动时设置 -app.state.logger = setup_global_logger(Config) - -# 在路由中使用 -logger = app.state.logger -``` - -### Q4: 如何处理同步函数? - -**A:** -使用 `anyio.to_thread.run_sync()` 在线程池中执行同步函数: - -```python -import anyio - -# 同步函数 -def sync_function(data): - # 耗时操作 - return result - -# 在异步函数中调用 -@app.post("/api") -async def api(request: Request): - result = await anyio.to_thread.run_sync(sync_function, data) - return result -``` - -### Q5: 如何添加中间件? - -**A:** -使用 `@app.middleware()` 装饰器: - -```python -@app.middleware("http") -async def add_process_time_header(request: Request, call_next): - start_time = time.time() - response = await call_next(request) - process_time = time.time() - start_time - response.headers["X-Process-Time"] = str(process_time) - return response -``` - -### Q6: 如何添加 CORS 支持? - -**A:** -使用 `CORSMiddleware`: - -```python -from fastapi.middleware.cors import CORSMiddleware - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # 允许所有源 - allow_credentials=True, - allow_methods=["*"], # 允许所有方法 - allow_headers=["*"], # 允许所有头 -) -``` - -### Q7: 如何添加认证? - -**A:** -使用依赖注入: - -```python -from fastapi import Depends, HTTPException -from fastapi.security import HTTPBearer - -security = HTTPBearer() - -async def verify_token(token: str = Depends(security)): - if token != "valid_token": - raise HTTPException(status_code=401, detail="Invalid token") - return token - -@app.post("/api") -async def api(token: str = Depends(verify_token)): - return {"message": "Authenticated"} -``` - ---- - -## 推荐资源 - -1. **官方文档**:https://fastapi.tiangolo.com/ -2. **中文文档**:https://fastapi.tiangolo.com/zh/ -3. **Pydantic 文档**:https://docs.pydantic.dev/ -4. **Uvicorn 文档**:https://www.uvicorn.org/ - ---- - -## 总结 - -FastAPI 是一个现代、高性能的 Web 框架,特别适合构建 API。相比 Flask,它提供了: - -1. **更好的性能**:异步支持,高性能 -2. **自动验证**:基于类型提示的自动数据验证 -3. **自动文档**:自动生成 API 文档 -4. **更好的开发体验**:类型提示、IDE 支持 - -通过这个项目,你可以学习到: -- FastAPI 的基本用法 -- 异步编程 -- 任务队列机制 -- 模块化架构设计 - -建议按照学习路径逐步深入,多实践、多思考,逐步掌握 FastAPI 的精髓。 - diff --git a/FASTAPI_QUICK_REFERENCE.md b/FASTAPI_QUICK_REFERENCE.md deleted file mode 100644 index 7fa5e03..0000000 --- a/FASTAPI_QUICK_REFERENCE.md +++ /dev/null @@ -1,496 +0,0 @@ -# FastAPI 快速参考指南 - -## 🚀 快速开始 - -### 最小示例 - -```python -from fastapi import FastAPI - -app = FastAPI() - -@app.get("/") -async def read_root(): - return {"Hello": "World"} - -@app.get("/items/{item_id}") -async def read_item(item_id: int): - return {"item_id": item_id} -``` - -### 运行应用 - -```bash -# 方式 1:使用 uvicorn -uvicorn main:app --reload - -# 方式 2:在代码中运行 -if __name__ == '__main__': - import uvicorn - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) -``` - ---- - -## 📝 路由定义 - -### HTTP 方法 - -```python -@app.get("/items") # GET 请求 -@app.post("/items") # POST 请求 -@app.put("/items/{id}") # PUT 请求 -@app.delete("/items/{id}") # DELETE 请求 -@app.patch("/items/{id}") # PATCH 请求 -``` - -### 路径参数 - -```python -@app.get("/items/{item_id}") -async def read_item(item_id: int): - return {"item_id": item_id} - -# 多个路径参数 -@app.get("/users/{user_id}/items/{item_id}") -async def read_user_item(user_id: int, item_id: int): - return {"user_id": user_id, "item_id": item_id} -``` - -### 查询参数 - -```python -@app.get("/items") -async def read_items(skip: int = 0, limit: int = 10): - return {"skip": skip, "limit": limit} - -# 可选参数 -@app.get("/items") -async def read_items(q: str = None): - return {"q": q} -``` - -### 请求体 - -```python -from pydantic import BaseModel - -class Item(BaseModel): - name: str - price: float - description: str = None # 可选字段 - -@app.post("/items") -async def create_item(item: Item): - return item -``` - ---- - -## 🔧 请求和响应 - -### 获取请求数据 - -```python -from fastapi import Request - -@app.post("/webhook") -async def webhook(request: Request): - # JSON 数据 - data = await request.json() - - # 请求头 - headers = request.headers - - # 查询参数 - query_params = request.query_params - - # 表单数据 - form_data = await request.form() - - # 文件上传 - files = await request.form() -``` - -### 响应类型 - -```python -from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse - -@app.get("/json") -async def json_response(): - return JSONResponse({"message": "Hello"}) - -@app.get("/html") -async def html_response(): - return HTMLResponse("

Hello

") - -@app.get("/text") -async def text_response(): - return PlainTextResponse("Hello") -``` - -### 状态码 - -```python -from fastapi import status - -@app.post("/items", status_code=status.HTTP_201_CREATED) -async def create_item(item: Item): - return item -``` - ---- - -## ✅ 数据验证(Pydantic) - -### 基本模型 - -```python -from pydantic import BaseModel, Field - -class User(BaseModel): - name: str - age: int = Field(gt=0, le=120) # 年龄必须大于 0,小于等于 120 - email: str = Field(..., regex="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$") -``` - -### 嵌套模型 - -```python -class Address(BaseModel): - street: str - city: str - -class User(BaseModel): - name: str - address: Address -``` - -### 列表和字典 - -```python -class Item(BaseModel): - tags: list[str] = [] - metadata: dict[str, str] = {} -``` - ---- - -## 🔐 依赖注入 - -### 基本依赖 - -```python -from fastapi import Depends - -def get_db(): - db = "database" - yield db - # 清理代码 - -@app.get("/items") -async def read_items(db: str = Depends(get_db)): - return {"db": db} -``` - -### 类依赖 - -```python -class Database: - def get_data(self): - return "data" - -def get_database(): - return Database() - -@app.get("/items") -async def read_items(db: Database = Depends(get_database)): - return db.get_data() -``` - ---- - -## 🛡️ 错误处理 - -### HTTP 异常 - -```python -from fastapi import HTTPException - -@app.get("/items/{item_id}") -async def read_item(item_id: int): - if item_id not in items: - raise HTTPException(status_code=404, detail="Item not found") - return {"item_id": item_id} -``` - -### 自定义异常处理器 - -```python -from fastapi import Request -from fastapi.responses import JSONResponse - -@app.exception_handler(ValueError) -async def value_error_handler(request: Request, exc: ValueError): - return JSONResponse( - status_code=400, - content={"message": str(exc)} - ) -``` - ---- - -## 🔄 异步编程 - -### 异步函数 - -```python -import asyncio - -@app.get("/async") -async def async_endpoint(): - await asyncio.sleep(1) # 模拟异步操作 - return {"message": "Done"} -``` - -### 在线程池中运行同步函数 - -```python -import anyio - -def sync_function(data): - # 同步耗时操作 - return result - -@app.post("/sync-in-async") -async def sync_in_async(data: dict): - result = await anyio.to_thread.run_sync(sync_function, data) - return result -``` - ---- - -## 📊 应用状态和生命周期 - -### 应用状态 - -```python -# 设置状态 -app.state.my_data = "value" - -# 获取状态 -my_data = app.state.my_data -``` - -### 启动和关闭事件 - -```python -@app.on_event("startup") -async def startup_event(): - # 启动时执行 - print("应用启动") - -@app.on_event("shutdown") -async def shutdown_event(): - # 关闭时执行 - print("应用关闭") -``` - ---- - -## 🎯 中间件 - -### 添加中间件 - -```python -@app.middleware("http") -async def add_process_time_header(request: Request, call_next): - start_time = time.time() - response = await call_next(request) - process_time = time.time() - start_time - response.headers["X-Process-Time"] = str(process_time) - return response -``` - -### CORS 中间件 - -```python -from fastapi.middleware.cors import CORSMiddleware - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) -``` - ---- - -## 📚 项目中的实际应用 - -### 1. 应用初始化(main.py) - -```python -app = FastAPI(title="简道云FastAPI服务") - -@app.on_event("startup") -def on_startup(): - app.state.app_tools = AppTools(Config) - app.state.logger = setup_global_logger(Config) - app.state.f6_module = F6Module() -``` - -### 2. 路由处理 - -```python -@app.post("/webhook") -async def webhook(request: Request): - # 获取状态 - logger = app.state.logger - app_tools = app.state.app_tools - - # 获取请求数据 - data = await request.json() - header = request.headers - - # 处理逻辑 - # ... - - # 返回响应 - return JSONResponse(result) -``` - -### 3. 任务队列集成 - -```python -# 将任务放入队列 -response_queue = app_tools.enqueue_task(handler, data) - -# 等待结果(在线程池中执行) -result = await anyio.to_thread.run_sync(response_queue.get) -``` - ---- - -## 🔍 调试技巧 - -### 1. 查看自动生成的文档 - -启动应用后访问: -- Swagger UI: http://localhost:8000/docs -- ReDoc: http://localhost:8000/redoc - -### 2. 打印请求信息 - -```python -@app.post("/webhook") -async def webhook(request: Request): - print(f"Method: {request.method}") - print(f"URL: {request.url}") - print(f"Headers: {dict(request.headers)}") - data = await request.json() - print(f"Body: {data}") -``` - -### 3. 使用日志 - -```python -import logging - -logger = logging.getLogger(__name__) - -@app.post("/webhook") -async def webhook(request: Request): - logger.info("收到请求") - logger.debug(f"请求数据: {data}") -``` - ---- - -## 📖 常用模式 - -### 1. 操作映射模式(项目中使用) - -```python -def get_action_map() -> dict: - return { - 'action1': handler1, - 'action2': handler2, - } - -@app.post("/api") -async def api(request: Request): - action_map = get_action_map() - action = request.headers.get('Action') - handler = action_map.get(action) - if handler: - result = handler(data) - return JSONResponse(result) -``` - -### 2. 统一错误处理 - -```python -@app.exception_handler(Exception) -async def global_exception_handler(request: Request, exc: Exception): - logger.error(f"未处理的异常: {exc}") - return JSONResponse( - status_code=500, - content={"message": "内部服务器错误"} - ) -``` - -### 3. 请求验证 - -```python -from pydantic import BaseModel, validator - -class WebhookData(BaseModel): - api_key: str - entry_id: str - data_id: str - - @validator('api_key') - def validate_api_key(cls, v): - if not v: - raise ValueError('api_key 不能为空') - return v - -@app.post("/webhook") -async def webhook(data: WebhookData): - # 数据已自动验证 - return {"received": data.dict()} -``` - ---- - -## 🎓 学习检查清单 - -- [ ] 理解 FastAPI 基本概念 -- [ ] 能够创建简单的路由 -- [ ] 理解异步编程(async/await) -- [ ] 掌握 Pydantic 数据验证 -- [ ] 理解依赖注入 -- [ ] 能够处理错误 -- [ ] 理解应用状态和生命周期 -- [ ] 能够添加中间件 -- [ ] 理解项目中的任务队列机制 -- [ ] 能够添加新的路由和功能 - ---- - -## 📚 相关资源 - -- **FastAPI 官方文档**: https://fastapi.tiangolo.com/ -- **FastAPI 中文文档**: https://fastapi.tiangolo.com/zh/ -- **Pydantic 文档**: https://docs.pydantic.dev/ -- **Uvicorn 文档**: https://www.uvicorn.org/ -- **Python 异步编程**: https://docs.python.org/3/library/asyncio.html - ---- - -**提示**: 这个快速参考指南可以作为日常开发的速查手册。建议结合 `FASTAPI_LEARNING.md` 深入学习。 - diff --git a/FASTAPI_README.md b/FASTAPI_README.md deleted file mode 100644 index ea2d5a9..0000000 --- a/FASTAPI_README.md +++ /dev/null @@ -1,352 +0,0 @@ -# FastAPI 学习资源集合 - -欢迎!这个目录包含了学习 FastAPI 的完整资源,特别适合从 Flask 迁移到 FastAPI 的开发者。 - -## 📚 文档列表 - -### 1. [FastAPI 学习文档](./FASTAPI_LEARNING.md) 📖 -**推荐首先阅读** - -全面的 FastAPI 学习指南,包括: -- FastAPI 简介和特点 -- Flask vs FastAPI 详细对比 -- FastAPI 核心概念详解 -- 项目中的实际应用分析 -- 学习路径建议 -- 常见问题解答 - -**适合**:想要系统学习 FastAPI 的初学者 - -### 2. [FastAPI 快速参考指南](./FASTAPI_QUICK_REFERENCE.md) ⚡ -**日常开发速查手册** - -快速查找常用功能的参考指南: -- 路由定义 -- 请求和响应处理 -- 数据验证 -- 依赖注入 -- 错误处理 -- 异步编程 -- 项目中的实际应用模式 - -**适合**:日常开发时快速查找语法和用法 - -### 3. [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md) 🔄 -**迁移项目必读** - -详细的迁移指南,包括: -- 迁移概览和步骤 -- 核心概念对比表 -- 代码迁移示例 -- 当前项目的迁移分析 -- 常见迁移问题解答 -- 迁移检查清单 - -**适合**:正在从 Flask 迁移到 FastAPI 的开发者 - ---- - -## 🎯 学习路径 - -### 阶段 1:基础入门(1-2 天) - -1. **阅读 [FastAPI 学习文档](./FASTAPI_LEARNING.md)** - - 理解 FastAPI 的基本概念 - - 了解 Flask vs FastAPI 的区别 - - 理解项目中的 FastAPI 使用 - -2. **运行项目** - ```bash - # 安装依赖 - pip install -r requirements.txt - - # 运行项目 - python main.py - # 或 - uvicorn main:app --reload - ``` - -3. **访问 API 文档** - - 启动后访问:http://localhost:5003/docs - - 查看自动生成的 Swagger UI 文档 - - 尝试调用 API 端点 - -### 阶段 2:深入理解(2-3 天) - -1. **分析项目代码** - - 阅读 `main.py`,理解应用初始化 - - 阅读 `app/module/` 下的业务模块 - - 理解任务队列机制 - -2. **参考 [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md)** - - 理解迁移过程 - - 对比 Flask 和 FastAPI 的写法 - - 理解项目中的迁移实现 - -3. **实践练习** - - 添加新的 API 端点 - - 添加数据验证 - - 添加错误处理 - -### 阶段 3:熟练应用(3-5 天) - -1. **使用 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md)** - - 在日常开发中参考 - - 尝试不同的 FastAPI 特性 - - 优化现有代码 - -2. **深入学习** - - 学习异步编程 - - 学习 Pydantic 高级特性 - - 学习依赖注入系统 - -3. **项目改进** - - 重构代码使用 FastAPI 最佳实践 - - 添加更多类型提示 - - 优化性能 - ---- - -## 🚀 快速开始 - -### 1. 安装依赖 - -```bash -pip install fastapi uvicorn -``` - -### 2. 最小示例 - -```python -from fastapi import FastAPI - -app = FastAPI() - -@app.get("/") -async def read_root(): - return {"Hello": "World"} - -@app.get("/items/{item_id}") -async def read_item(item_id: int): - return {"item_id": item_id} -``` - -### 3. 运行应用 - -```bash -uvicorn main:app --reload -``` - -### 4. 访问文档 - -- Swagger UI: http://localhost:8000/docs -- ReDoc: http://localhost:8000/redoc - ---- - -## 📖 项目结构 - -``` -fastapi_app/ -├── main.py # FastAPI 应用入口 -├── api.py # API 工具类 -├── app/ -│ ├── api.py # 简道云 API 封装 -│ ├── config.py # 配置管理 -│ ├── module/ # 业务模块 -│ │ ├── F6_Plugin_module.py -│ │ ├── module.py -│ │ └── other_module.py -│ ├── tasks/ # 后台任务 -│ └── utils/ # 工具函数 -│ └── app_tools.py -├── requirements.txt # 依赖列表 -│ -├── FASTAPI_LEARNING.md # 📖 学习文档 -├── FASTAPI_QUICK_REFERENCE.md # ⚡ 快速参考 -└── FLASK_TO_FASTAPI_MIGRATION.md # 🔄 迁移指南 -``` - ---- - -## 🔑 关键概念 - -### 1. 应用实例 - -```python -from fastapi import FastAPI - -app = FastAPI(title="简道云FastAPI服务") -``` - -### 2. 路由定义 - -```python -@app.post("/webhook") -async def webhook(request: Request): - data = await request.json() - return JSONResponse(result) -``` - -### 3. 应用状态 - -```python -# 启动时设置 -app.state.logger = setup_global_logger(Config) - -# 路由中使用 -logger = app.state.logger -``` - -### 4. 生命周期事件 - -```python -@app.on_event("startup") -def on_startup(): - # 初始化代码 - pass -``` - ---- - -## 💡 项目中的实际应用 - -### 1. 应用初始化 - -```python -# main.py -app = FastAPI(title="简道云FastAPI服务") - -@app.on_event("startup") -def on_startup(): - app.state.app_tools = AppTools(Config) - app.state.logger = setup_global_logger(Config) - app.state.f6_module = F6Module() -``` - -### 2. 路由处理 - -```python -@app.post("/webhook") -async def webhook(request: Request): - logger = app.state.logger - app_tools = app.state.app_tools - - data = await request.json() - header = request.headers - - # 处理逻辑 - result = await anyio.to_thread.run_sync(response_queue.get) - - return JSONResponse(result) -``` - -### 3. 任务队列 - -```python -# 将任务放入队列 -response_queue = app_tools.enqueue_task(handler, data) - -# 在线程池中执行同步函数 -result = await anyio.to_thread.run_sync(response_queue.get) -``` - ---- - -## 📚 推荐资源 - -### 官方文档 -- **FastAPI 官方文档**: https://fastapi.tiangolo.com/ -- **FastAPI 中文文档**: https://fastapi.tiangolo.com/zh/ -- **Pydantic 文档**: https://docs.pydantic.dev/ -- **Uvicorn 文档**: https://www.uvicorn.org/ - -### 学习资源 -- **FastAPI 教程**: https://fastapi.tiangolo.com/tutorial/ -- **Python 异步编程**: https://docs.python.org/3/library/asyncio.html -- **类型提示**: https://docs.python.org/3/library/typing.html - ---- - -## ❓ 常见问题 - -### Q: 我应该先读哪个文档? - -**A:** 建议按以下顺序: -1. 先读 [FastAPI 学习文档](./FASTAPI_LEARNING.md) 了解基础 -2. 再读 [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md) 理解迁移 -3. 日常开发时参考 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md) - -### Q: 如何理解项目中的异步代码? - -**A:** -- FastAPI 使用 `async/await` 处理异步操作 -- `await request.json()` 异步获取 JSON 数据 -- `await anyio.to_thread.run_sync()` 在线程池中执行同步函数 -- 详细说明见 [FastAPI 学习文档](./FASTAPI_LEARNING.md#异步编程) - -### Q: 项目中的任务队列是如何工作的? - -**A:** -- 使用 Python 的 `Queue` 和 `threading` 实现 -- 请求到达后,任务放入队列 -- 后台线程处理任务 -- 使用 `anyio.to_thread.run_sync()` 等待结果 -- 详细说明见 [FastAPI 学习文档](./FASTAPI_LEARNING.md#项目中的-fastapi-使用) - -### Q: 如何添加新的 API 端点? - -**A:** -1. 在 `main.py` 中添加路由函数 -2. 在 `get_action_map()` 中注册操作(如果需要) -3. 参考 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md#路由定义) - ---- - -## 🎓 学习检查清单 - -### 基础理解 -- [ ] 理解 FastAPI 的基本概念 -- [ ] 理解 Flask vs FastAPI 的区别 -- [ ] 能够创建简单的路由 -- [ ] 理解异步编程(async/await) - -### 项目理解 -- [ ] 理解项目结构 -- [ ] 理解应用初始化流程 -- [ ] 理解路由处理逻辑 -- [ ] 理解任务队列机制 - -### 实践能力 -- [ ] 能够添加新的路由 -- [ ] 能够使用 Pydantic 进行数据验证 -- [ ] 能够处理错误 -- [ ] 能够使用应用状态 - -### 深入学习 -- [ ] 理解依赖注入 -- [ ] 理解中间件 -- [ ] 理解生命周期事件 -- [ ] 能够优化代码性能 - ---- - -## 📝 更新日志 - -- **2024-01-XX**: 创建 FastAPI 学习文档集合 - - 添加 FastAPI 学习文档 - - 添加快速参考指南 - - 添加 Flask 到 FastAPI 迁移指南 - ---- - -## 🤝 贡献 - -如果你发现文档中有错误或需要改进的地方,欢迎提出建议! - ---- - -**祝你学习愉快!** 🎉 - -如有问题,请参考相应的文档或查看 FastAPI 官方文档。 - diff --git a/FLASK_TO_FASTAPI_MIGRATION.md b/FLASK_TO_FASTAPI_MIGRATION.md deleted file mode 100644 index b5ec434..0000000 --- a/FLASK_TO_FASTAPI_MIGRATION.md +++ /dev/null @@ -1,620 +0,0 @@ -# Flask 到 FastAPI 迁移指南 - -## 📋 目录 - -1. [迁移概览](#迁移概览) -2. [核心概念对比](#核心概念对比) -3. [代码迁移示例](#代码迁移示例) -4. [项目迁移分析](#项目迁移分析) -5. [常见迁移问题](#常见迁移问题) - ---- - -## 迁移概览 - -### 为什么迁移到 FastAPI? - -1. **性能提升**:FastAPI 性能接近 NodeJS 和 Go -2. **自动文档**:自动生成 API 文档 -3. **类型安全**:基于 Python 类型提示 -4. **异步支持**:原生支持 async/await -5. **现代特性**:符合现代 Python 开发标准 - -### 迁移步骤 - -1. ✅ **安装 FastAPI**:`pip install fastapi uvicorn` -2. ✅ **替换应用实例**:`Flask()` → `FastAPI()` -3. ✅ **更新路由装饰器**:基本语法相同 -4. ✅ **处理异步**:添加 `async/await` -5. ✅ **数据验证**:使用 Pydantic 模型 -6. ✅ **更新响应**:使用 FastAPI 响应类 - ---- - -## 核心概念对比 - -### 1. 应用实例 - -#### Flask -```python -from flask import Flask - -app = Flask(__name__) -``` - -#### FastAPI -```python -from fastapi import FastAPI - -app = FastAPI() -``` - -### 2. 路由定义 - -#### Flask -```python -@app.route('/items', methods=['GET']) -def get_items(): - return jsonify({'items': []}) -``` - -#### FastAPI -```python -@app.get('/items') -async def get_items(): - return {'items': []} -``` - -**区别**: -- FastAPI 使用 `@app.get()` 而不是 `@app.route(methods=['GET'])` -- FastAPI 函数通常是 `async` -- FastAPI 直接返回字典,自动转换为 JSON - -### 3. 获取请求数据 - -#### Flask -```python -from flask import request - -@app.route('/items', methods=['POST']) -def create_item(): - data = request.get_json() - name = data.get('name') - return jsonify({'name': name}) -``` - -#### FastAPI -```python -from fastapi import Request -from pydantic import BaseModel - -class Item(BaseModel): - name: str - -@app.post('/items') -async def create_item(item: Item): - return {'name': item.name} -``` - -**区别**: -- FastAPI 使用 Pydantic 模型自动验证 -- 不需要手动获取 JSON 数据 -- 类型自动验证和转换 - -### 4. 路径参数 - -#### Flask -```python -@app.route('/items/') -def get_item(item_id): - return jsonify({'item_id': item_id}) -``` - -#### FastAPI -```python -@app.get('/items/{item_id}') -async def get_item(item_id: int): - return {'item_id': item_id} -``` - -**区别**: -- FastAPI 使用 `{item_id}` 而不是 `` -- 类型在函数参数中指定 - -### 5. 查询参数 - -#### Flask -```python -from flask import request - -@app.route('/items') -def get_items(): - skip = request.args.get('skip', 0, type=int) - limit = request.args.get('limit', 10, type=int) - return jsonify({'skip': skip, 'limit': limit}) -``` - -#### FastAPI -```python -@app.get('/items') -async def get_items(skip: int = 0, limit: int = 10): - return {'skip': skip, 'limit': limit} -``` - -**区别**: -- FastAPI 查询参数直接在函数参数中定义 -- 默认值自动处理 -- 类型自动验证 - -### 6. 请求头 - -#### Flask -```python -from flask import request - -@app.route('/items') -def get_items(): - user_agent = request.headers.get('User-Agent') - return jsonify({'user_agent': user_agent}) -``` - -#### FastAPI -```python -from fastapi import Request, Header - -@app.get('/items') -async def get_items(request: Request): - user_agent = request.headers.get('user-agent') - return {'user_agent': user_agent} - -# 或者使用 Header -@app.get('/items') -async def get_items(user_agent: str = Header(None)): - return {'user_agent': user_agent} -``` - -### 7. 响应 - -#### Flask -```python -from flask import jsonify, Response - -@app.route('/items') -def get_items(): - return jsonify({'items': []}) - # 或 - return Response('text', mimetype='text/plain') -``` - -#### FastAPI -```python -from fastapi.responses import JSONResponse, PlainTextResponse - -@app.get('/items') -async def get_items(): - return {'items': []} # 自动转换为 JSON - # 或 - return JSONResponse({'items': []}) - # 或 - return PlainTextResponse('text') -``` - -### 8. 错误处理 - -#### Flask -```python -from flask import abort - -@app.route('/items/') -def get_item(item_id): - if item_id not in items: - abort(404, description="Item not found") - return jsonify({'item_id': item_id}) -``` - -#### FastAPI -```python -from fastapi import HTTPException - -@app.get('/items/{item_id}') -async def get_item(item_id: int): - if item_id not in items: - raise HTTPException(status_code=404, detail="Item not found") - return {'item_id': item_id} -``` - -### 9. 应用状态 - -#### Flask -```python -from flask import g - -@app.before_request -def before_request(): - g.db = get_database() - -@app.route('/items') -def get_items(): - db = g.db - return jsonify({'items': []}) -``` - -#### FastAPI -```python -@app.on_event("startup") -def startup(): - app.state.db = get_database() - -@app.get('/items') -async def get_items(): - db = app.state.db - return {'items': []} -``` - -### 10. 中间件 - -#### Flask -```python -@app.before_request -def before_request(): - # 请求前处理 - pass - -@app.after_request -def after_request(response): - # 请求后处理 - response.headers['X-Custom'] = 'value' - return response -``` - -#### FastAPI -```python -@app.middleware("http") -async def add_custom_header(request: Request, call_next): - # 请求前处理 - response = await call_next(request) - # 请求后处理 - response.headers['X-Custom'] = 'value' - return response -``` - ---- - -## 代码迁移示例 - -### 示例 1:简单的 API 端点 - -#### Flask 版本 -```python -from flask import Flask, request, jsonify - -app = Flask(__name__) - -@app.route('/api/users', methods=['POST']) -def create_user(): - data = request.get_json() - if not data or 'name' not in data: - return jsonify({'error': '缺少 name 字段'}), 400 - - name = data['name'] - age = data.get('age', 0) - - return jsonify({'name': name, 'age': age}), 201 -``` - -#### FastAPI 版本 -```python -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel - -app = FastAPI() - -class User(BaseModel): - name: str - age: int = 0 - -@app.post('/api/users', status_code=201) -async def create_user(user: User): - return {'name': user.name, 'age': user.age} -``` - -**改进点**: -- ✅ 自动数据验证 -- ✅ 类型安全 -- ✅ 更简洁的代码 -- ✅ 自动生成 API 文档 - -### 示例 2:带路径参数的端点 - -#### Flask 版本 -```python -@app.route('/api/users/', methods=['GET']) -def get_user(user_id): - if user_id not in users: - return jsonify({'error': '用户不存在'}), 404 - return jsonify(users[user_id]) -``` - -#### FastAPI 版本 -```python -@app.get('/api/users/{user_id}') -async def get_user(user_id: int): - if user_id not in users: - raise HTTPException(status_code=404, detail='用户不存在') - return users[user_id] -``` - -### 示例 3:文件上传 - -#### Flask 版本 -```python -from flask import request - -@app.route('/upload', methods=['POST']) -def upload_file(): - if 'file' not in request.files: - return jsonify({'error': '没有文件'}), 400 - - file = request.files['file'] - # 处理文件 - return jsonify({'filename': file.filename}) -``` - -#### FastAPI 版本 -```python -from fastapi import UploadFile, File - -@app.post('/upload') -async def upload_file(file: UploadFile = File(...)): - # 处理文件 - return {'filename': file.filename} -``` - ---- - -## 项目迁移分析 - -### 当前项目的迁移情况 - -#### 1. 应用初始化(main.py) - -**Flask 版本(推测)**: -```python -from flask import Flask - -app = Flask(__name__) - -@app.before_first_request -def initialize(): - app.config['app_tools'] = AppTools(Config) - # ... -``` - -**FastAPI 版本(当前)**: -```python -from fastapi import FastAPI - -app = FastAPI(title="简道云FastAPI服务") - -@app.on_event("startup") -def on_startup(): - app.state.app_tools = AppTools(Config) - app.state.logger = setup_global_logger(Config) - # ... -``` - -**迁移要点**: -- ✅ `Flask()` → `FastAPI()` -- ✅ `@app.before_first_request` → `@app.on_event("startup")` -- ✅ `app.config` → `app.state` - -#### 2. 路由处理(main.py) - -**Flask 版本(推测)**: -```python -@app.route('/webhook', methods=['POST']) -def webhook(): - data = request.get_json() - header = request.headers - # 处理逻辑 - return jsonify(result) -``` - -**FastAPI 版本(当前)**: -```python -@app.post("/webhook") -async def webhook(request: Request): - data = await request.json() - header = request.headers - # 处理逻辑 - return JSONResponse(result) -``` - -**迁移要点**: -- ✅ `@app.route(methods=['POST'])` → `@app.post()` -- ✅ `def` → `async def` -- ✅ `request.get_json()` → `await request.json()` -- ✅ `jsonify()` → `JSONResponse()` 或直接返回字典 - -#### 3. 任务队列处理 - -**项目特点**: -- 使用自定义任务队列(`Queue` + `threading`) -- 在异步函数中调用同步函数 - -**FastAPI 处理方式**: -```python -# 在线程池中执行同步函数 -result = await anyio.to_thread.run_sync(response_queue.get) -``` - -**迁移要点**: -- ✅ 使用 `anyio.to_thread.run_sync()` 在线程池中执行同步代码 -- ✅ 保持原有的任务队列机制 - ---- - -## 常见迁移问题 - -### Q1: 如何处理 Flask 的 `g` 对象? - -**A:** 使用 `app.state` 或依赖注入: - -```python -# Flask -from flask import g -g.db = get_db() - -# FastAPI 方式 1:使用 app.state -app.state.db = get_db() -db = app.state.db - -# FastAPI 方式 2:使用依赖注入(推荐) -def get_db(): - db = "database" - yield db - -@app.get("/items") -async def get_items(db: str = Depends(get_db)): - return {"db": db} -``` - -### Q2: 如何处理 Flask 的 `session`? - -**A:** FastAPI 不内置 session,需要手动实现或使用第三方库: - -```python -from fastapi import FastAPI, Request -from starlette.middleware.sessions import SessionMiddleware - -app = FastAPI() -app.add_middleware(SessionMiddleware, secret_key="secret") - -@app.post("/login") -async def login(request: Request): - request.session['user'] = 'username' - return {"message": "Logged in"} -``` - -### Q3: 如何处理 Flask 的 `url_for`? - -**A:** 使用 `Request` 对象构建 URL: - -```python -from fastapi import Request - -@app.get("/items/{item_id}") -async def get_item(item_id: int, request: Request): - url = str(request.url_for('get_item', item_id=item_id)) - return {"url": url} -``` - -### Q4: 如何迁移 Flask 的蓝图(Blueprint)? - -**A:** 使用 FastAPI 的 `APIRouter`: - -```python -from fastapi import APIRouter - -router = APIRouter() - -@router.get("/items") -async def get_items(): - return {"items": []} - -# 在主应用中注册 -app.include_router(router, prefix="/api") -``` - -### Q5: 如何处理 Flask 的 `current_app`? - -**A:** 使用依赖注入或直接访问 `app`: - -```python -# Flask -from flask import current_app -config = current_app.config - -# FastAPI -from fastapi import Request - -@app.get("/config") -async def get_config(request: Request): - app = request.app - # 访问应用配置 - return {"config": "value"} -``` - -### Q6: 如何迁移 Flask 的 `before_request` 和 `after_request`? - -**A:** 使用中间件: - -```python -@app.middleware("http") -async def middleware(request: Request, call_next): - # before_request 逻辑 - print("请求前") - - response = await call_next(request) - - # after_request 逻辑 - print("请求后") - response.headers["X-Custom"] = "value" - - return response -``` - ---- - -## 迁移检查清单 - -### 基础迁移 -- [ ] 替换 `Flask()` 为 `FastAPI()` -- [ ] 更新路由装饰器(`@app.route()` → `@app.get()` 等) -- [ ] 添加 `async` 关键字 -- [ ] 更新请求数据获取方式 -- [ ] 更新响应返回方式 - -### 高级迁移 -- [ ] 使用 Pydantic 模型进行数据验证 -- [ ] 迁移应用状态(`app.config` → `app.state`) -- [ ] 更新生命周期事件(`before_first_request` → `on_event("startup")`) -- [ ] 迁移中间件 -- [ ] 更新错误处理 - -### 测试和优化 -- [ ] 测试所有 API 端点 -- [ ] 验证数据验证功能 -- [ ] 检查性能提升 -- [ ] 更新文档 -- [ ] 更新依赖项 - ---- - -## 总结 - -### 迁移优势 - -1. **性能提升**:FastAPI 性能显著优于 Flask -2. **开发效率**:自动文档、自动验证减少开发时间 -3. **类型安全**:类型提示提供更好的 IDE 支持 -4. **现代特性**:异步支持、依赖注入等现代特性 - -### 注意事项 - -1. **异步编程**:需要理解 `async/await` -2. **Pydantic**:需要学习 Pydantic 模型定义 -3. **依赖注入**:FastAPI 的依赖注入系统需要适应 -4. **生态系统**:Flask 的某些扩展可能需要替代方案 - -### 建议 - -1. **逐步迁移**:不要一次性迁移所有代码 -2. **保持兼容**:在迁移过程中保持 API 兼容性 -3. **充分测试**:迁移后充分测试所有功能 -4. **学习资源**:参考 FastAPI 官方文档和示例 - ---- - -**提示**:这个迁移指南帮助你理解从 Flask 到 FastAPI 的迁移过程。结合 `FASTAPI_LEARNING.md` 和 `FASTAPI_QUICK_REFERENCE.md` 一起学习效果更好。 - diff --git a/README.md b/README.md new file mode 100644 index 0000000..018b1ee --- /dev/null +++ b/README.md @@ -0,0 +1,391 @@ +# 简道云 FastAPI 服务 + +简道云插件后端服务,提供数据同步和处理功能。 + +## 📋 项目简介 + +本项目是一个基于 FastAPI 框架的简道云插件后端服务,主要用于处理简道云插件发送的请求,执行各种业务操作。系统与 F6 汽车维修管理系统集成,提供以下核心功能: + +- **F6系统集成**: 登录认证、公司信息获取、门店信息管理 +- **文件处理**: 文件上传、校验、Excel批量处理 +- **数据管理**: 品牌批量创建、客户信息管理、车辆信息管理 +- **数据清理**: 历史记录删除、客户删除、车辆删除 +- **BI任务**: 数据处理和报表生成 +- **工作流自动化**: 自动提交简道云工作流 + +## 🏗️ 项目结构 + +``` +fastapi_app/ +├── main.py # 应用入口,生命周期管理 +├── requirements.txt # Python依赖清单 +├── README.md # 项目文档(本文件) +│ +├── app/ # 应用主目录 +│ ├── __init__.py +│ ├── config.py # 配置管理(目录、API Token等) +│ ├── schemas.py # Pydantic数据模型定义 +│ │ +│ ├── api/ # API路由模块 +│ │ ├── __init__.py # 简道云API封装类 +│ │ ├── routes.py # FastAPI路由定义 +│ │ └── dependencies.py # 依赖注入函数 +│ │ +│ ├── core/ # 核心功能模块 +│ │ ├── __init__.py # 核心管理器 +│ │ └── module_registry.py # 模块注册表 +│ │ +│ ├── module/ # 业务模块 +│ │ ├── __init__.py +│ │ ├── module.py # F6Module - F6系统相关功能 +│ │ ├── f6_plugin_module.py # F6PluginModule - F6插件功能 +│ │ └── other_module.py # OtherPluginModule - 其他功能 +│ │ +│ ├── tasks/ # 后台任务模块 +│ │ ├── __init__.py # 任务统一导出 +│ │ ├── common.py # 通用任务函数 +│ │ ├── brand_tasks.py # 品牌相关任务 +│ │ ├── customer_tasks.py # 客户相关任务 +│ │ ├── delete_tasks.py # 删除相关任务 +│ │ └── bi_tasks.py # BI相关任务 +│ │ +│ └── utils/ # 工具模块 +│ └── app_tools.py # 应用工具类(日志、任务队列等) +│ +├── logs/ # 日志目录 +├── 下载文件/ # 下载文件存储目录 +└── 模板文件/ # 模板文件存储目录 +``` + +## 🚀 快速开始 + +### 环境要求 + +- Python 3.8+ +- pip +- Tesseract OCR(用于验证码识别,可选) + +### 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 配置说明 + +编辑 `app/config.py` 文件,配置以下内容: + +- `JIANDAOYUN_API_TOKEN`: 简道云API Token(生产环境建议使用环境变量) + +### 运行应用 + +```bash +# 方式1:直接运行 +python main.py + +# 方式2:使用 uvicorn(推荐) +uvicorn main:app --host 0.0.0.0 --port 5003 --reload +``` + +### 访问API文档 + +启动应用后,访问以下地址查看API文档: + +- **Swagger UI**: http://localhost:5003/docs +- **ReDoc**: http://localhost:5003/redoc + +## 📡 API 接口 + +### 健康检查 + +```http +GET /health +``` + +**响应示例:** +```json +{ + "status": "ok", + "version": "1.0.0" +} +``` + +### Webhook 接口 + +```http +POST /webhook +``` + +**请求头:** +- `Action`: 操作类型(必需) +- `Check`: 检查标志(F6_Plugin 操作时需要,值为"是"或"否") + +**请求体:** +```json +{ + "api_key": "简道云应用ID", + "entry_id": "简道云表单ID", + "data_id": "简道云数据ID", + "Action": "操作类型(F6_Plugin 操作时需要)", + "username": "用户名", + "password": "密码", + "company_name": "公司名称" +} +``` + +**响应示例:** +```json +{ + "msg": "操作完成", + "msg_details": "详细信息", + "status": "success" +} +``` + +## 🔧 支持的操作 + +### F6Module 操作 + +| 操作名 | 描述 | 说明 | +|--------|------|------| +| `login_in` | F6系统登录 | 使用用户名、密码登录F6系统,支持验证码识别 | +| `get_company_information` | 获取公司信息 | 获取F6系统中的公司列表,保存到简道云 | +| `get_store_information` | 获取门店信息 | 获取指定公司的门店列表及统计数据 | +| `keep_alive` | 保持连接 | 心跳检测,保持连接活跃 | + +### F6PluginModule 操作 + +| 操作名 | 描述 | 说明 | +|--------|------|------| +| `check_file` | 校验上传文件 | 校验Excel文件格式,支持品牌创建、客户修改等场景 | +| `create_brand` | 创建品牌 | 批量创建品牌,从Excel文件读取品牌名称 | +| `delete_history` | 删除历史记录 | 删除指定门店的历史维修记录 | +| `delete_customer` | 删除客户 | 批量删除客户信息(会检查会员卡和消费记录) | +| `delete_cars` | 删除车辆 | 批量删除客户车辆信息(会检查会员卡和消费记录) | +| `modify_customer_info` | 修改客户信息 | 批量修改客户信息,从Excel文件读取修改内容 | +| `bi_task` | BI任务 | 执行BI相关任务(数据处理、报表生成等) | + +### OtherPluginModule 操作 + +| 操作名 | 描述 | 说明 | +|--------|------|------| +| `sms_signature_status` | 短信签名状态 | 查询短信签名状态(待实现) | + +## 🛠️ 技术栈 + +- **FastAPI**: 现代、快速的 Web 框架,支持异步处理 +- **Pydantic**: 数据验证和设置管理 +- **Uvicorn**: ASGI 服务器 +- **APScheduler**: 后台任务调度 +- **Requests**: HTTP 请求库 +- **Pandas**: Excel文件处理和数据分析 +- **Pillow**: 图像处理 +- **Pytesseract**: OCR 识别(用于验证码识别) + +## 📦 依赖列表 + +``` +anyio==4.11.0 +apscheduler==3.11.1 +fastapi==0.121.0 +log_config==2.1.1 +numpy==2.3.4 +pandas==2.3.3 +Pillow==12.0.0 +pytesseract==0.3.13 +Requests==2.32.5 +tqdm==4.67.1 +uvicorn==0.38.0 +``` + +## 🏛️ 架构设计 + +### 核心特性 + +1. **模块化设计**: 业务逻辑按模块分离,易于维护和扩展 +2. **依赖注入**: 使用 FastAPI 的依赖注入系统,减少耦合 +3. **模块注册表**: 使用 `module_registry` 统一管理所有操作 +4. **路由分离**: API 路由分离到独立文件,职责清晰 +5. **数据验证**: 使用 Pydantic schemas 进行请求和响应验证 +6. **异常处理**: 完善的异常处理机制,统一的错误响应格式 +7. **日志记录**: 规范的日志记录,支持日志轮转(单文件5MB,保留5个备份) + +### 生命周期管理 + +应用使用 FastAPI 的 `lifespan` 功能管理应用生命周期: + +- **启动时**: + - 初始化工具类(AppTools) + - 设置全局日志记录器 + - 初始化业务模块(F6Module、F6PluginModule、OtherPluginModule) + - 注册所有操作到模块注册表 +- **运行时**: 处理请求,执行业务逻辑 +- **关闭时**: 清理资源,关闭后台调度器 + +### 任务处理机制 + +系统支持两种任务处理方式: + +1. **同步任务**: + - 通过任务队列同步执行,等待结果返回 + - 适用于快速操作(如登录、信息获取) + - 超时时间:55秒(简道云默认60秒超时) + +2. **后台任务**: + - 在模块函数内部启动线程执行耗时操作 + - 立即返回"正在执行中"的提示 + - 适用于批量操作(如品牌创建、数据删除) + - 执行完成后自动更新简道云表单并提交工作流 + +### 简道云API封装 + +`app/api/__init__.py` 中的 `API` 类封装了简道云的所有API接口: + +- **表单数据操作**: 获取、创建、更新、删除(单条/批量) +- **表单字段获取**: 获取表单字段信息,支持字段ID到标签名的替换 +- **工作流操作**: 获取实例、审批、转交 +- **文件操作**: 获取上传凭证、上传文件 +- **重试机制**: 所有API调用都支持失败重试(默认最多20次) + +## 🔐 配置说明 + +配置文件位于 `app/config.py`,主要配置项: + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| `BASE_DIR` | 项目根目录 | 自动获取 | +| `SAVE_DIRECTORY` | 下载文件保存目录 | `下载文件/` | +| `MODE_DIRECTORY` | 模板文件保存目录 | `模板文件/` | +| `LOGS_DIRECTORY` | 日志文件目录 | `logs/` | +| `LOG_FILE` | 日志文件路径 | `logs/简道云.log` | +| `JIANDAOYUN_API_TOKEN` | 简道云API Token | 需配置 | + +## 📝 开发指南 + +### 添加新操作 + +1. **在模块中实现方法**: + ```python + # app/module/your_module.py + class YourModule: + def your_action(self, data: Dict[str, Any]) -> Dict[str, str]: + # 实现业务逻辑 + return {'msg': '操作完成'} + ``` + +2. **在 main.py 中注册**: + ```python + # 在 lifespan 函数中 + core_manager.register_action( + 'your_action', + your_module.your_action, + 'your_module', + description='操作描述' + ) + ``` + +### 添加新路由 + +在 `app/api/routes.py` 中添加新的路由: + +```python +@router.post("/your-endpoint", tags=["业务"]) +async def your_endpoint( + request: Request, + logger: logging.Logger = Depends(get_logger) +): + # 实现路由逻辑 + return {"message": "success"} +``` + +### 添加新的数据模型 + +在 `app/schemas.py` 中添加新的 Pydantic 模型: + +```python +class YourRequest(BaseModel): + field1: str = Field(..., description="字段1") + field2: Optional[int] = Field(None, description="字段2") +``` + +### 添加后台任务 + +1. **在 tasks 目录下创建任务文件**: + ```python + # app/tasks/your_tasks.py + def your_task_background(data, ...): + # 实现后台任务逻辑 + # 完成后调用 update_jiandaoyun 和 approve_workflow + ``` + +2. **在模块中调用**: + ```python + import threading + from app.tasks.your_tasks import your_task_background + + thread = threading.Thread(target=your_task_background, args=(...)) + thread.start() + return {'msg': '正在执行中'} + ``` + +## 🧪 测试 + +### 健康检查测试 + +```bash +curl http://localhost:5003/health +``` + +### Webhook 测试 + +```bash +curl -X POST http://localhost:5003/webhook \ + -H "Content-Type: application/json" \ + -H "Action: login_in" \ + -d '{ + "username": "test", + "password": "test", + "company_name": "测试公司" + }' +``` + +## 📊 日志 + +日志文件位于 `logs/简道云.log`,支持日志轮转: + +- 单个日志文件最大 5MB +- 保留 5 个备份文件 +- 使用 UTF-8 编码 +- 日志格式:`时间戳 级别:模块名:消息` + +## ⚠️ 注意事项 + +1. **API Token**: 当前 API Token 硬编码在配置文件中,生产环境建议使用环境变量管理 +2. **CORS**: 当前配置允许所有来源,生产环境建议限制允许的来源 +3. **超时时间**: 任务执行超时时间默认为 55 秒,可根据需要调整 +4. **文件存储**: 下载的文件存储在 `下载文件/` 目录,注意定期清理 +5. **验证码识别**: 需要系统安装 Tesseract OCR 才能正常识别验证码 +6. **删除操作**: 删除客户和车辆时会检查会员卡和消费记录,有记录的数据会被跳过 +7. **批量操作**: 批量操作在后台线程中执行,不会阻塞主请求 + +## 🔄 版本历史 + +- **v1.0.0**: 初始版本 + - 实现基本的 Webhook 接口 + - 支持 F6 系统相关操作 + - 支持文件上传和校验 + - 支持品牌、客户、车辆管理 + - 支持数据删除操作 + - 支持BI任务处理 + +## 📄 许可证 + +本项目为内部项目,仅供内部使用。 + +## 📞 联系方式 + +如有问题,请联系数据组。 + +--- + +**最后更新**: 2025年 diff --git a/app/API_UPDATE.md b/app/API_UPDATE.md deleted file mode 100644 index e31d5ba..0000000 --- a/app/API_UPDATE.md +++ /dev/null @@ -1,186 +0,0 @@ -# API模块更新说明 - -## 更新内容 - -根据 `fastapi_app/api.py`(最新版)更新了 `fastapi_app/app/api.py`,主要更新包括: - -### 1. 添加失败重试机制 - -所有API函数都已添加重试机制,参考 `entry_data_list` 的实现模式: - -- **默认重试次数**:20次(部分函数为10次) -- **重试逻辑**: - - 捕获 `requests.exceptions.RequestException` 异常 - - 重试前等待时间:0.1秒(快速请求)或3-10秒(慢速请求) - - 超过最大重试次数后记录错误日志并返回None或抛出异常 - -### 2. 新增功能函数 - -添加了以下新函数(原版本中没有的): - -- `entry_data_banch_update()` - 批量修改数据 -- `entry_data_delete()` - 删除单条数据 -- `entry_data_batch_delete()` - 批量删除数据 -- `workflow_task_hand_over()` - 流程待办转交 -- `get_upload_token()` - 获取文件上传凭证 -- `upload_file()` - 上传文件 - -### 3. 功能增强 - -- **`entry_data_get()`**: - - 添加 `replace` 参数(默认True,保持向后兼容) - - 添加 `max_retries` 参数 - - 添加重试机制 - -- **`entry_data_list()`**: - - 添加 `replace` 参数(默认True,保持向后兼容) - - 添加 `max_retries` 参数 - - 改进重试逻辑,支持分页重试 - -- **`entry_widget_list()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - -- **`data_batch_create()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - - 支持 `is_start_workflow`、`is_start_trigger`、`transaction_id` 参数 - -- **`entry_data_batch_create()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - - 支持 `is_start_workflow`、`is_start_trigger` 参数 - - 使用 `NpEncoder` 处理NumPy数据类型 - -- **`entry_data_update()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - -- **`workflow_instance_get()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - -- **`workflow_task_approve()`**: - - 添加 `max_retries` 参数 - - 添加重试机制 - - `comment` 参数支持自定义(默认"自动转交") - -### 4. 工具函数 - -- **`NpEncoder`**:JSON编码器,处理NumPy数据类型 -- **`replace_decimals()`**:递归替换Decimal类型为float - -### 5. 字段替换优化 - -`field_replacement()` 方法使用递归实现,更优雅地处理嵌套数据结构。 - -## 兼容性保证 - -### 向后兼容 - -1. **默认参数**: - - `entry_data_get()` 和 `entry_data_list()` 的 `replace` 参数默认为 `True`,保持原有行为 - - 所有新增的 `max_retries` 参数都有合理的默认值 - -2. **导入路径**: - - 使用 `from app.config import Config`(而非 `from config import Config`) - - 使用 `logging.getLogger('app')`(而非 `log_config`) - -3. **函数签名**: - - 所有原有函数的调用方式保持不变 - - 新增参数都是可选参数 - -### 日志系统 - -- 使用 `logging.getLogger('app')` 作为常规日志记录器 -- 使用 `logging.getLogger('app.error')` 作为错误日志记录器 -- 与 fastapi_app 项目的日志系统完全兼容 - -## 使用示例 - -### 基本使用(保持原有方式) - -```python -from app.api import API - -api = API() - -# 获取单条数据(自动替换字段) -data = api.entry_data_get({ - 'api_key': 'xxx', - 'entry_id': 'xxx', - 'data_id': 'xxx' -}) - -# 获取多条数据(自动替换字段) -data_list = api.entry_data_list({ - 'api_key': 'xxx', - 'entry_id': 'xxx' -}) -``` - -### 使用新功能 - -```python -# 不替换字段 -data = api.entry_data_get({ - 'api_key': 'xxx', - 'entry_id': 'xxx', - 'data_id': 'xxx' -}, replace=False) - -# 自定义重试次数 -data = api.entry_data_get({ - 'api_key': 'xxx', - 'entry_id': 'xxx', - 'data_id': 'xxx' -}, max_retries=10) - -# 批量删除 -result = api.entry_data_batch_delete({ - 'api_key': 'xxx', - 'entry_id': 'xxx', - 'data_ids': ['id1', 'id2', 'id3'] -}, chunk_size=90, max_retries=20) -``` - -## 重试机制说明 - -### 重试策略 - -1. **快速请求**(0.1秒延迟): - - `entry_data_list()` - 分页请求 - - `entry_data_batch_create()` - 批量创建 - - `entry_data_batch_delete()` - 批量删除 - - `workflow_instance_get()` - 流程查询 - -2. **慢速请求**(3-10秒延迟): - - `data_batch_create()` - 3秒延迟 - - `entry_data_update()` - 10秒延迟 - - `entry_data_banch_update()` - 10秒延迟 - - `entry_data_delete()` - 10秒延迟 - - `workflow_task_approve()` - 3秒延迟 - - `workflow_task_hand_over()` - 3秒延迟 - - `get_upload_token()` - 3秒延迟 - - `upload_file()` - 3秒延迟 - -### 错误处理 - -- 所有重试失败的操作都会记录到错误日志 -- 部分函数在重试失败后返回 `None`,部分会抛出异常 -- 错误日志包含失败的任务标识信息 - -## 注意事项 - -1. **超时设置**:所有请求都设置了 `timeout=10` 秒 -2. **状态码检查**:使用 `res.raise_for_status()` 检查HTTP状态码 -3. **文件上传**:`upload_file()` 使用 `with` 语句确保文件正确关闭 -4. **数据类型处理**:批量操作函数使用 `NpEncoder` 和 `replace_decimals()` 处理特殊数据类型 - -## 测试建议 - -1. 测试所有原有功能的兼容性 -2. 测试新添加的函数 -3. 测试重试机制(可以模拟网络错误) -4. 测试 `replace=False` 参数的使用 - diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index fe16459..0000000 --- a/app/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__all__ = [] - diff --git a/app/api.py b/app/api.py deleted file mode 100644 index 4ee8302..0000000 --- a/app/api.py +++ /dev/null @@ -1,793 +0,0 @@ -""" -API 模块 - 简道云API接口封装 -支持失败重试机制,兼容现有代码 -""" -import requests -import json -import time -import logging -from typing import Optional, List, Dict, Any -from decimal import Decimal -import numpy as np -from app.config import Config - -# 获取日志记录器 -logger = logging.getLogger('app') -error_logger = logging.getLogger('app.error') # 错误日志记录器 - - -class NpEncoder(json.JSONEncoder): - """NumPy数据类型JSON编码器""" - def default(self, obj): - if isinstance(obj, np.integer): - return int(obj) - elif isinstance(obj, np.floating): - return float(obj) - elif isinstance(obj, np.ndarray): - return obj.tolist() - else: - return super(NpEncoder, self).default(obj) - - -def replace_decimals(obj): - """递归替换Decimal类型为float""" - if isinstance(obj, dict): - return {k: replace_decimals(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [replace_decimals(item) for item in obj] - elif isinstance(obj, Decimal): - return float(obj) - return obj - - -class API: - def entry_data_get(self, data: dict, replace: bool = True, max_retries: int = 20) -> Dict: - """ - 获取单条表单数据 - :param replace: 是否替换字段,默认为True(保持向后兼容) - :param max_retries: 最大重试次数 - :param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息 - :return: 表单数据 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/get' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_id": data['data_id'] - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - print(data_get) - - if replace: - data_get = self.field_replacement(data, data_get) - - return data_get - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - if retries <= max_retries: - time.sleep(0.1) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") - raise Exception(f"获取单条表单数据失败,已重试{max_retries}次") - - def entry_data_list(self, data: dict, replace: bool = True, max_retries: int = 20) -> Dict: - """ - 获取多条表单数据 - :param max_retries: 最大重试次数 - :param replace: 是否替换字段,默认为True(保持向后兼容) - :param data: 简道云插件发送过来的data,包含应用id、表单id等信息 - :return: 表单数据列表 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/list' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - all_data_batches = [] - last_data_id = None - exit_flag = False - - while True: - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "limit": 100, - "data_id": last_data_id - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - - if data_get.get("data"): - all_data_batches.extend(data_get['data']) - last_data_id = data_get['data'][-1].get('_id') - print(f"已获取 {len(all_data_batches)} 条数据") - break - else: - if 'data' not in data_get or len(data_get['data']) == 0: - exit_flag = True - break - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(0.1) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(0.1) - - if retries > max_retries: - error_logger.error(f"任务 {last_data_id}组 连续{max_retries}次请求失败,放弃此次请求。") - all_data_batches.append(None) - - if exit_flag: - break - - final_data = { - 'data': all_data_batches - } - - logger.info(f"获取了{len(all_data_batches)}条数据") - - if replace: - print("进行了替换") - return self.field_replacement(data, final_data) - else: - return final_data - - @staticmethod - def entry_widget_list(data: dict, max_retries: int = 20) -> Optional[Dict[str, Any]]: - """ - 获取表单字段 - :param max_retries: 最大重试次数 - :param data: 简道云插件发送过来的data,包含应用id、表单id等信息 - :return: 表单字段信息 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/widget/list' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - return res.json() - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - if retries <= max_retries: - time.sleep(0.1) - - if retries > max_retries: - error_logger.error(f"获取表单字段失败,已重试{max_retries}次") - return None - - def field_replacement(self, data: dict, data_get: dict) -> dict: - """ - 字段替换,将id替换为标签名,即唯一值替换为表单中显示字段的名字 - :param data: 简道云插件发送过来的data,包含表单id、数据id、应用id - :param data_get: 简道云请求的数据,一般是根据数据id获取到表单的数据 - :return: 替换后的数据 - """ - widget_list = self.entry_widget_list(data) - - if not widget_list or 'widgets' not in widget_list or not isinstance(widget_list['widgets'], list): - raise ValueError("映射表没有接受到数据") - - name_to_label = {widget['name']: widget['label'] for widget in widget_list['widgets']} - - def replace_keys(obj): - """递归替换字典中的键名""" - if isinstance(obj, dict): - new_dict = {} - for key, value in obj.items(): - new_key = name_to_label.get(key, key) - new_dict[new_key] = replace_keys(value) - return new_dict - elif isinstance(obj, list): - return [replace_keys(item) for item in obj] - else: - return obj - - data_get_copy = json.loads(json.dumps(data_get)) - - if 'data' in data_get_copy: - data_get_copy['data'] = replace_keys(data_get_copy['data']) - - return data_get_copy - - @staticmethod - def data_batch_create(data: dict, max_retries: int = 20) -> Optional[Dict]: - """ - 新建单条表单数据 - :param max_retries: 最大重试次数 - :param data: 应该包含应用id、表单id,以及新建的数据data['data'] - :return: 返回创建后简道云返回的信息 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/create' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data": data['data'], - "is_start_workflow": data.get('is_start_workflow', "false"), - "is_start_trigger": data.get('is_start_trigger', "false"), - "transaction_id": data.get('transaction_id', "") - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - if res.status_code == 200: - return data_get - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data')} 连续{max_retries}次请求失败,放弃此次请求。") - return None - - @staticmethod - def entry_data_batch_create( - data: dict, - chunk_size: int = 90, - max_retries: int = 20 - ) -> List[Optional[Dict]]: - """ - 新建多条数据 - :param max_retries: 最大重试次数 - :param data: 应包含数据id、表单id、以及需要新建的信息,新建信息应该是一个列表 - :param chunk_size: 简道云限制批量新建一次最多100条,这里默认值设置为90条一次 - :return: 返回请求后的结果 - """ - data = replace_decimals(data) - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_create' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - total_length = len(data['data_list']) - logger.info(f"多数据写入行数: {total_length}") - - num_chunks = (total_length + chunk_size - 1) // chunk_size - data_get_list = [] - - for i in range(num_chunks): - start_index = i * chunk_size - end_index = min(start_index + chunk_size, total_length) - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_list": data['data_list'][start_index:end_index], - "is_start_workflow": data.get('is_start_workflow', "false"), - "is_start_trigger": data.get('is_start_trigger', "false"), - }, cls=NpEncoder) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - if data_get.get("status") == "success": - data_get_list.append(data_get) - break - else: - logger.warning(f"请求异常,将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(0.1) - - if retries > max_retries: - error_logger.error( - f"任务 {data['data_list'][start_index:end_index]} 连续{max_retries}次请求失败,放弃此次请求。") - data_get_list.append(None) - - return data_get_list - - @staticmethod - def entry_data_update(data: dict, max_retries: int = 20) -> dict: - """ - 修改数据 - :param max_retries: 最大重试次数 - :param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息 - :return: 修改数据后简道云返回的结果 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/update' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_id": data['data_id'], - "data": data['data'] - }) - - data_get = None - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - if res.status_code == 200: - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(10) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") - - return data_get - - @staticmethod - def entry_data_banch_update(data: dict, max_retries: int = 20, chunk_size: int = 90) -> List[dict]: - """ - 批量修改数据 - :param chunk_size: 批量修改块大小 - :param max_retries: 最大重试次数 - :param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息 - :return: 修改数据后简道云返回的结果列表 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_update' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - total_length = len(data['data_ids']) - logger.info(f"多数据修改行数: {total_length}") - - num_chunks = (total_length + chunk_size - 1) // chunk_size - data_get_list = [] - - for i in range(num_chunks): - start_index = i * chunk_size - end_index = min(start_index + chunk_size, total_length) - payload = { - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_ids": data['data_ids'][start_index:end_index], - "data": data['data'] - } - - if "transaction_id" in data: - payload["transaction_id"] = data["transaction_id"] - payload = json.dumps(payload, cls=NpEncoder) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - if res.status_code == 200: - data_get_list.append(data_get) - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(10) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data_ids')} 连续{max_retries}次请求失败,放弃此次请求。") - continue - - return data_get_list - - @staticmethod - def entry_data_delete(data: dict, max_retries: int = 20) -> dict: - """ - 删除单条数据 - :param data: 应包含应用ID、表单ID、数据ID - :param max_retries: 最大重试次数,默认20 - :return: 删除结果 - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/delete' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_id": data['data_id'], - }) - - retries = 0 - delete_status = None - - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - delete_status = res.json() - - # 手动处理状态码 4001(数据不存在) - if delete_status == { - "code": 4001, - "msg": "Data does not exist." - }: - logger.info(f"返回结果: {delete_status}") - break - - res.raise_for_status() - - if res.status_code == 200: - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(10) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") - continue - - return delete_status - - @staticmethod - def entry_data_batch_delete( - data: dict, - chunk_size: int = 90, - max_retries: int = 20 - ) -> List[Optional[Dict]]: - """ - 批量删除数据 - :param data: 应包含应用ID、表单ID、数据ID列表 - :param chunk_size: 单次删除最大条数,默认90 - :param max_retries: 重试次数,默认20 - :return: 删除结果列表 - """ - data = replace_decimals(data) - url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_delete' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - total_length = len(data['data_ids']) - logger.info(f"多数据删除行数: {total_length}") - - num_chunks = (total_length + chunk_size - 1) // chunk_size - data_get_list = [] - - for i in range(num_chunks): - start_index = i * chunk_size - end_index = min(start_index + chunk_size, total_length) - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "data_ids": data['data_ids'][start_index:end_index], - }, cls=NpEncoder) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - logger.info(f"{i}页 返回结果: {data_get}") - if data_get.get("status") == "success": - data_get_list.append(data_get) - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(0.1) - - if retries > max_retries: - error_logger.error( - f"批量删除任务第{i+1}批 连续{max_retries}次请求失败,放弃此次请求。") - data_get_list.append(None) - - return data_get_list - - @staticmethod - def workflow_instance_get(data: dict, max_retries: int = 20) -> dict: - """ - 查询实例流程信息 - :param max_retries: 最大重试次数 - :param data: 简道云插件发送过来的data,包含应用id - :return: 查询简道云流程实例信息返回的结果 - """ - url = 'https://api.jiandaoyun.com/api/v5/workflow/instance/get' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "instance_id": data['data_id'], - "tasks_type": 1 - }) - - data_get = None - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - data_get = res.json() - if res.status_code == 200: - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(0.1) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") - - return data_get - - @staticmethod - def workflow_task_approve(data: dict, max_retries: int = 20) -> dict: - """ - 流程待办提交 - :param max_retries: 最大重试次数 - :param data: 应包含username、instance_id(data_id)、task_id等信息 - :return: 返回简道云流程待办提交的结果 - """ - url = 'https://api.jiandaoyun.com/api/v1/workflow/task/approve' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "username": data["username"], - "instance_id": data["instance_id"], - "task_id": data['task_id'], - "comment": data.get("comment", "自动转交") - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - if res.status_code == 200: - return res.json() - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - - if retries > max_retries: - error_logger.error(f"任务 {data.get('task_id')} 连续{max_retries}次请求失败,放弃此次请求。") - return {} - - @staticmethod - def workflow_task_hand_over(data: dict, max_retries: int = 10) -> Optional[dict]: - """ - 流程待办转交 - :param max_retries: 最大重试次数 - :param data: 应包含username、instance_id(data_id)、task_id、transfer_username等信息 - :return: 返回简道云流程待办转交的结果 - """ - url = 'https://api.jiandaoyun.com/api/v1/workflow/task/transfer' - - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "username": data["username"], - "instance_id": data["instance_id"], - "task_id": data['task_id'], - "transfer_username": data['transfer_username'], - "comment": data.get("comment", "转交") - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - if res.status_code == 200: - return res.json() - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - - if retries > max_retries: - error_logger.error(f"任务转交失败,已重试{max_retries}次") - return None - - @staticmethod - def get_upload_token(data: dict, max_retries: int = 10) -> Optional[Dict[str, Any]]: - """ - 获取文件上传凭证 - :param max_retries: 最大重试次数 - :param data: 应包含应用ID、表单ID、事务ID - :return: 返回upload_url、upload_token - """ - url = 'https://api.jiandaoyun.com/api/v5/app/entry/file/get_upload_token' - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - 'Content-Type': 'application/json' - } - - payload = json.dumps({ - "app_id": data['api_key'], - "entry_id": data['entry_id'], - "transaction_id": data['transaction_id'], - }) - - retries = 0 - while retries <= max_retries: - try: - res = requests.post(url=url, data=payload, headers=headers, timeout=10) - res.raise_for_status() - res_j = res.json() - - # 检查 token_and_url_list 是否存在且不为空 - token_list = res_j.get('token_and_url_list', []) - if not token_list or len(token_list) == 0: - logger.warning(f"未获取到上传凭证列表,将重新请求") - retries += 1 - time.sleep(3) - continue - - token_item = token_list[0] - upload_url = token_item.get('url') - upload_token = token_item.get('token') - - if not upload_url or not upload_token: - logger.warning(f"上传凭证信息不完整,将重新请求") - retries += 1 - time.sleep(3) - continue - - logger.info(f"返回结果: {upload_url}, {upload_token}") - if res.status_code == 200: - return { - 'upload_url': upload_url, - 'upload_token': upload_token - } - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except (requests.exceptions.RequestException, KeyError, IndexError, TypeError) as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - - if retries > max_retries: - error_logger.error(f"获取上传凭证失败,已重试{max_retries}次") - return None - - @staticmethod - def upload_file(data: dict, max_retries: int = 10) -> Optional[Any]: - """ - 上传文件 - :param max_retries: 最大重试次数 - :param data: 应包含上传文件路径、上传文件url、上传文件token - :return: 返回上传文件结果 - """ - url = data['upload_url'] - headers = { - 'Authorization': Config.JIANDAOYUN_API_TOKEN, - } - file_path = data['file_path'] - payload = { - "token": data['upload_token'], - } - - retries = 0 - result = None - - while retries <= max_retries: - try: - with open(file_path, 'rb') as f: - files = {"file": f} - res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10) - res.raise_for_status() - data_get = res.json() - logger.info(f"返回结果: {data_get}") - if res.status_code == 200: - result = data_get - break - else: - logger.warning(f"请求异常, 将重新请求") - retries += 1 - time.sleep(3) - except requests.exceptions.RequestException as e: - logger.warning(f"请求异常: {e}, 将重新请求") - retries += 1 - time.sleep(3) - - if retries > max_retries: - error_logger.error(f"上传文件失败,已重试{max_retries}次") - - return result diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..61b07b1 --- /dev/null +++ b/app/api/dependencies.py @@ -0,0 +1,148 @@ +""" +FastAPI 依赖注入模块 + +本模块提供 FastAPI 的依赖注入函数,用于在路由中注入可复用的依赖项。 +使用依赖注入可以减少代码重复,提高可测试性和可维护性。 + +提供的依赖项: +- get_logger: 获取日志记录器 +- get_app_tools: 获取应用工具实例 +- get_f6_module: 获取 F6Module 实例 +- get_f6_plugin_module: 获取 F6PluginModule 实例 +- get_other_module: 获取 OtherPluginModule 实例 +- get_action_map: 获取操作映射表 +""" +from fastapi import Request +from typing import Optional +import logging + + +def get_logger(request: Request) -> logging.Logger: + """ + 获取日志记录器依赖项 + + 从应用状态中获取日志记录器,如果未初始化则返回一个基本的 logger。 + + Args: + request: FastAPI 请求对象 + + Returns: + logging.Logger: 日志记录器实例 + """ + logger = getattr(request.app.state, 'logger', None) + if logger is None: + # 如果 logger 未初始化,返回一个基本的 logger + logger = logging.getLogger('app') + return logger + + +def get_app_tools(request: Request): + """ + 获取应用工具实例依赖项 + + 从应用状态中获取 AppTools 实例,用于任务队列管理等操作。 + + Args: + request: FastAPI 请求对象 + + Returns: + AppTools: 应用工具实例 + + Raises: + RuntimeError: 如果 AppTools 未初始化 + """ + from app.utils.app_tools import AppTools + app_tools = getattr(request.app.state, 'app_tools', None) + if app_tools is None: + raise RuntimeError("AppTools 未初始化") + return app_tools + + +def get_f6_module(request: Request): + """ + 获取 F6Module 实例依赖项 + + 从应用状态中获取 F6Module 实例,用于 F6 系统相关操作。 + + Args: + request: FastAPI 请求对象 + + Returns: + F6Module: F6Module 实例 + + Raises: + RuntimeError: 如果 F6Module 未初始化 + """ + f6_module = getattr(request.app.state, 'f6_module', None) + if f6_module is None: + raise RuntimeError("F6Module 未初始化") + return f6_module + + +def get_f6_plugin_module(request: Request): + """ + 获取 F6PluginModule 实例依赖项 + + 从应用状态中获取 F6PluginModule 实例,用于 F6 插件相关操作。 + + Args: + request: FastAPI 请求对象 + + Returns: + F6PluginModule: F6PluginModule 实例 + + Raises: + RuntimeError: 如果 F6PluginModule 未初始化 + """ + f6_plugin_module = getattr(request.app.state, 'f6_plugin_module', None) + if f6_plugin_module is None: + raise RuntimeError("F6PluginModule 未初始化") + return f6_plugin_module + + +def get_other_module(request: Request): + """ + 获取 OtherPluginModule 实例依赖项 + + 从应用状态中获取 OtherPluginModule 实例,用于其他插件相关操作。 + + Args: + request: FastAPI 请求对象 + + Returns: + OtherPluginModule: OtherPluginModule 实例 + + Raises: + RuntimeError: 如果 OtherPluginModule 未初始化 + """ + other_module = getattr(request.app.state, 'other_module', None) + if other_module is None: + raise RuntimeError("OtherPluginModule 未初始化") + return other_module + + +def get_action_map(request: Request) -> dict: + """ + 获取操作映射表依赖项 + + 从模块注册表中获取所有注册的操作,并转换为字典格式(操作名 -> 处理函数)。 + + Args: + request: FastAPI 请求对象 + + Returns: + dict: 操作映射表,格式为 {操作名: 处理函数} + """ + from app.core.module_registry import registry + + # 从 registry 获取所有注册的操作 + actions = registry.get_all_actions() + + # 转换为字典格式(handler 函数) + action_map = { + action_name: config.handler + for action_name, config in actions.items() + } + + return action_map + diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..f2ce006 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,171 @@ +""" +API 路由定义模块 + +本模块定义所有 API 路由端点,包括: +- /health: 健康检查端点 +- /webhook: Webhook 端点,处理简道云插件的请求 + +所有路由都使用 FastAPI 的依赖注入系统,通过 dependencies.py 中的函数注入依赖项。 +""" +from fastapi import APIRouter, Request, HTTPException, status, Depends +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from typing import Dict, Any +import json +import anyio +import asyncio +import logging + +from app.schemas import WebhookRequest, WebhookResponse, HealthResponse +from app.api.dependencies import ( + get_logger, + get_app_tools, + get_f6_plugin_module, + get_action_map +) +from app.utils.app_tools import AppTools + +# 创建路由器 +# 使用 APIRouter 分离路由,便于管理和维护 +router = APIRouter() + + +@router.get("/health", response_model=HealthResponse, tags=["系统"]) +async def healthcheck(): + """ + 健康检查端点 + + 用于检查服务是否正常运行 + """ + return HealthResponse(status="ok", version="1.0.0") + + +@router.post("/webhook", response_model=WebhookResponse, tags=["业务"]) +async def webhook( + request: Request, + logger: logging.Logger = Depends(get_logger), + app_tools: AppTools = Depends(get_app_tools), + f6_plugin_module = Depends(get_f6_plugin_module), + action_map: Dict[str, Any] = Depends(get_action_map) +): + """ + 接受前端请求后将任务放入消息队列 + + 此端点接收简道云插件的请求,根据请求头中的 Action 字段路由到相应的处理函数。 + 支持的操作包括:登录、获取公司信息、文件校验、品牌创建等。 + + Args: + request: FastAPI 请求对象,包含请求体和请求头 + logger: 日志记录器 + app_tools: 应用工具实例 + f6_plugin_module: F6插件模块实例 + action_map: 操作映射表 + + Returns: + WebhookResponse: 任务处理结果 + + Raises: + HTTPException: 当操作类型无效或任务执行超时时抛出 + """ + try: + # 获取请求数据并验证 + try: + raw_data = await request.json() + # 使用 Pydantic 进行数据验证(允许额外字段) + webhook_data = WebhookRequest(**raw_data) + data = webhook_data.dict(exclude_none=True) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请求体必须是有效的 JSON 格式" + ) + except Exception as e: + logger.warning(f"请求数据验证失败: {str(e)}") + # 如果验证失败,仍然尝试使用原始数据(向后兼容) + data = raw_data if 'raw_data' in locals() else {} + + # 获取并解码请求头 + header = request.headers + decoded_header = app_tools.decode_headers(header) + + # 验证 Action 字段 + action = decoded_header.get('Action') + if not action: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请求头中缺少必需的 Action 字段" + ) + + # 处理 F6_Plugin 特殊逻辑 + if action == 'F6_Plugin': + check = decoded_header.get('Check') + if check == '否': + handler = f6_plugin_module.check_file + elif check == '是': + sub_action = data.get('Action') + if not sub_action: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="F6_Plugin 操作需要提供 Action 字段" + ) + handler = action_map.get(sub_action) + if not handler: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"未知的子操作类型: {sub_action}" + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"F6_Plugin 操作需要提供有效的 Check 字段(是/否),当前值: {check}" + ) + else: + handler = action_map.get(action) + if not handler: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"未知的操作类型: {action}。支持的操作: {', '.join(action_map.keys())}" + ) + + logger.info(f"接收到操作请求: {action}, 数据ID: {data.get('data_id', 'N/A')}") + + # 将任务放入消息队列 + response_queue = app_tools.enqueue_task(handler, data) + + # 等待任务处理结果(添加超时保护,简道云默认60秒) + try: + # 使用 asyncio.wait_for 添加超时 + result = await asyncio.wait_for( + anyio.to_thread.run_sync(response_queue.get), + timeout=55.0 + ) + except asyncio.TimeoutError: + logger.error(f"任务执行超时: {action}, 数据ID: {data.get('data_id', 'N/A')}") + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail="任务执行超时,请稍后重试" + ) + + # 验证返回结果格式 + if not isinstance(result, dict): + result = {"msg": str(result)} + + if "msg" not in result: + result["msg"] = "操作完成" + + logger.info(f"操作完成: {action}, 结果: {json.dumps(result, ensure_ascii=False)}") + + # 返回响应(使用 Pydantic 模型验证) + return WebhookResponse(**result) + + except HTTPException: + # 重新抛出 HTTP 异常 + raise + except Exception as e: + # 捕获其他未预期的异常 + logger.error(f"处理请求时发生未预期的错误: {type(e).__name__} - {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"处理请求时发生错误: {str(e)}" + ) + diff --git a/app/back_ground_tasks.py b/app/back_ground_tasks.py deleted file mode 100644 index e9b3420..0000000 --- a/app/back_ground_tasks.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -后台任务模块 - 向后兼容入口 -此文件保持向后兼容,实际功能已拆分到 app.tasks 模块中 -""" -# 从新的 tasks 模块导入所有函数,保持向后兼容 -from app.tasks import ( - update_jiandaoyun, - approve_workflow, - create_brand_background, - delete_history_background, - delete_customer_background, - delete_car_background, - modify_customer_info_background, -) - -__all__ = [ - 'update_jiandaoyun', - 'approve_workflow', - 'create_brand_background', - 'delete_history_background', - 'delete_customer_background', - 'delete_car_background', - 'modify_customer_info_background', -] diff --git a/app/config.py b/app/config.py index 73fdf11..670962e 100644 --- a/app/config.py +++ b/app/config.py @@ -1,29 +1,59 @@ +""" +应用配置模块 + +本模块负责管理应用的所有配置项,包括: +- 目录路径配置 +- API Token 配置 +- 日志配置 + +注意:生产环境建议将敏感信息(如 API Token)移至环境变量。 +""" from pathlib import Path # 获取当前文件所在的目录 +# 当前文件位于 app/config.py,parent.parent 获取项目根目录 BASE_DIR = Path(__file__).resolve().parent.parent # 项目根目录 # 构建保存下载文件的目录路径 +# 用于存储从简道云下载的文件 SAVE_DIRECTORY = BASE_DIR / '下载文件' # 构建保存模板文件的目录路径 +# 用于存储模板文件 MODE_DIRECTORY = BASE_DIR / '模板文件' # 构建日志文件的目录路径 +# 用于存储应用日志文件 LOGS_DIRECTORY = BASE_DIR / 'logs' +# 日志文件路径 LOG_FILE = LOGS_DIRECTORY / '简道云.log' # 确保目录存在,如果不存在则创建 +# 在应用启动时自动创建必要的目录 SAVE_DIRECTORY.mkdir(parents=True, exist_ok=True) MODE_DIRECTORY.mkdir(parents=True, exist_ok=True) # API 配置 +# 简道云 API Token,用于调用简道云 API +# 注意:生产环境建议使用环境变量管理此配置 JIANDAOYUN_API_TOKEN = 'Bearer qygHulymo1fekJk4CIZyNKjyQAzG8CFN' # 曹伟应用api测试 app_key -# 导出配置 class Config: + """ + 应用配置类 + + 统一管理应用的所有配置项,方便在应用中使用。 + + 属性: + BASE_DIR: 项目根目录路径 + SAVE_DIRECTORY: 下载文件保存目录 + MODE_DIRECTORY: 模板文件保存目录 + JIANDAOYUN_API_TOKEN: 简道云 API Token + LOGS_DIRECTORY: 日志文件目录 + LOG_FILE: 日志文件路径 + """ BASE_DIR = BASE_DIR SAVE_DIRECTORY = SAVE_DIRECTORY MODE_DIRECTORY = MODE_DIRECTORY diff --git a/app/core/__init__.py b/app/core/__init__.py index 2d0b927..94ba699 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,17 +1,28 @@ """ 核心模块初始化 -统一初始化请求头管理器、模块注册表等核心组件 + +本模块统一初始化和管理核心组件,包括: +- ModuleRegistry: 模块注册表 +- CoreManager: 核心管理器 + +提供统一的接口来管理这些核心组件。 """ from typing import Dict, Any, Callable -from app.core.header_manager import HeaderManager from app.core.module_registry import ModuleRegistry, registry class CoreManager: - """核心管理器 - 统一管理所有核心组件""" + """ + 核心管理器 + + 统一管理所有核心组件,提供便捷的方法来初始化和注册模块。 + + 属性: + registry: 模块注册表实例 + """ def __init__(self): - self.header_manager = HeaderManager + """初始化核心管理器""" self.registry = registry def initialize_modules(self, modules: Dict[str, Any]): @@ -49,12 +60,12 @@ class CoreManager: # 全局核心管理器实例 +# 在应用启动时使用此实例来注册模块和操作 core_manager = CoreManager() # 导出常用类和函数 __all__ = [ 'core_manager', - 'HeaderManager', 'ModuleRegistry', 'registry', ] diff --git a/app/core/header_manager.py b/app/core/header_manager.py deleted file mode 100644 index 0574191..0000000 --- a/app/core/header_manager.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -请求头管理器 -统一管理不同模块的请求头配置 -""" -from typing import Dict, Optional -from dataclasses import dataclass, field - - -@dataclass -class HeaderConfig: - """请求头配置""" - referer: Optional[str] = None - user_agent: Optional[str] = None - content_type: Optional[str] = None - authorization: Optional[str] = None - custom_headers: Dict[str, str] = field(default_factory=dict) - - def to_dict(self) -> Dict[str, str]: - """转换为字典格式""" - headers = {} - - if self.referer: - headers['Referer'] = self.referer - if self.user_agent: - headers['User-Agent'] = self.user_agent - if self.content_type: - headers['Content-Type'] = self.content_type - if self.authorization: - headers['Authorization'] = self.authorization - - # 添加自定义请求头 - headers.update(self.custom_headers) - - return headers - - -class HeaderManager: - """请求头管理器""" - - # 默认请求头配置 - DEFAULT_HEADERS = HeaderConfig( - referer='https://yunxiu.f6car.cn/erp/view/index.html', - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0' - ) - - # F6系统登录请求头 - F6_LOGIN_HEADERS = HeaderConfig( - referer='https://yunxiu.f6car.com/kzf6/login/confirm', - user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.0.0' - ) - - # 简道云API请求头 - JIANDAOYUN_API_HEADERS = HeaderConfig( - content_type='application/json', - # authorization 应该从配置中获取,这里只是示例 - ) - - # 模块特定的请求头配置 - _module_headers: Dict[str, HeaderConfig] = { - 'default': DEFAULT_HEADERS, - 'f6_login': F6_LOGIN_HEADERS, - 'jiandaoyun_api': JIANDAOYUN_API_HEADERS, - } - - @classmethod - def get_headers(cls, module_name: str = 'default', **overrides) -> Dict[str, str]: - """ - 获取指定模块的请求头 - - Args: - module_name: 模块名称 - **overrides: 覆盖的请求头配置 - - Returns: - 请求头字典 - """ - # 获取基础配置 - config = cls._module_headers.get(module_name, cls.DEFAULT_HEADERS) - - # 创建配置副本 - header_config = HeaderConfig( - referer=overrides.get('referer', config.referer), - user_agent=overrides.get('user_agent', config.user_agent), - content_type=overrides.get('content_type', config.content_type), - authorization=overrides.get('authorization', config.authorization), - custom_headers={**config.custom_headers, **overrides.get('custom_headers', {})} - ) - - return header_config.to_dict() - - @classmethod - def register_module_headers(cls, module_name: str, config: HeaderConfig): - """ - 注册模块的请求头配置 - - Args: - module_name: 模块名称 - config: 请求头配置 - """ - cls._module_headers[module_name] = config - - @classmethod - def update_module_headers(cls, module_name: str, **updates): - """ - 更新模块的请求头配置 - - Args: - module_name: 模块名称 - **updates: 要更新的配置项 - """ - if module_name in cls._module_headers: - config = cls._module_headers[module_name] - for key, value in updates.items(): - if hasattr(config, key): - setattr(config, key, value) - else: - config.custom_headers[key] = value - diff --git a/app/core/module_registry.py b/app/core/module_registry.py index 46af03e..c691e5e 100644 --- a/app/core/module_registry.py +++ b/app/core/module_registry.py @@ -1,6 +1,12 @@ """ -模块注册和路由管理 -提供统一的模块注册机制,方便添加新功能模块 +模块注册表模块 + +本模块提供统一的模块注册机制,用于管理所有业务模块和操作。 +使用注册表模式可以: +- 统一管理所有操作 +- 方便添加新功能模块 +- 支持动态路由和操作查找 +- 提供操作元数据管理 """ from typing import Dict, Callable, Optional, Any from dataclasses import dataclass @@ -8,7 +14,18 @@ from dataclasses import dataclass @dataclass class ActionConfig: - """操作配置""" + """ + 操作配置数据类 + + 存储操作的配置信息,包括处理函数、所属模块、描述等。 + + 属性: + handler: 处理函数,执行具体业务逻辑 + module_name: 所属模块名称 + description: 操作描述,用于文档和日志 + requires_auth: 是否需要认证,默认 True + header_module: 使用的请求头模块名称,可选 + """ handler: Callable # 处理函数 module_name: str # 所属模块名称 description: Optional[str] = None # 描述 @@ -17,9 +34,19 @@ class ActionConfig: class ModuleRegistry: - """模块注册表""" + """ + 模块注册表类 + + 统一管理所有业务模块和操作的注册表。 + 支持注册模块实例和操作,并提供查询功能。 + + 属性: + _actions: 操作字典,格式为 {操作名: ActionConfig} + _modules: 模块字典,格式为 {模块名: 模块信息} + """ def __init__(self): + """初始化模块注册表""" self._actions: Dict[str, ActionConfig] = {} self._modules: Dict[str, Dict[str, Any]] = {} @@ -65,7 +92,12 @@ class ModuleRegistry: return self._actions.get(action_name) def get_all_actions(self) -> Dict[str, ActionConfig]: - """获取所有注册的操作""" + """ + 获取所有注册的操作 + + Returns: + Dict[str, ActionConfig]: 所有注册的操作字典的副本 + """ return self._actions.copy() def register_module(self, module_name: str, module_instance: Any, **metadata): @@ -113,4 +145,5 @@ class ModuleRegistry: # 全局模块注册表实例 +# 在应用启动时使用此实例来注册所有模块和操作 registry = ModuleRegistry() diff --git a/app/module/F6_Plugin_module.py b/app/module/F6_Plugin_module.py index 8d96be0..1b3dfc8 100644 --- a/app/module/F6_Plugin_module.py +++ b/app/module/F6_Plugin_module.py @@ -1,26 +1,57 @@ +""" +F6 插件模块 + +本模块提供 F6 插件相关的功能,包括: +- 文件上传和校验 +- 品牌批量创建 +- 历史记录删除 +- 客户信息管理 +- 车辆信息管理 + +依赖: +- requests: HTTP 请求 +- pandas: Excel 文件处理 +- threading: 后台任务处理 +""" import requests from urllib.parse import quote import pandas as pd import os import urllib.parse from datetime import datetime -from app.api import API from typing import Optional, Dict, Any, Tuple -from app.config import Config -from app.module import F6Module import threading -from app import back_ground_tasks +from app.api import API +from app.config import Config +from app.module.module import F6Module +from app.tasks.brand_tasks import create_brand_background +from app.tasks.delete_tasks import ( + delete_history_background, + delete_customer_background, + delete_car_background +) +from app.tasks.customer_tasks import modify_customer_info_background +from app.tasks.bi_tasks import bi_task_background + +# 简道云 API 实例,用于调用简道云 API api_instance = API() class F6PluginModule: + """ + F6 插件模块类 + + 提供 F6 插件相关的所有功能,包括文件处理、品牌管理、数据删除等。 + """ @staticmethod - def accept_file(data: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: # 接收文件 + def accept_file(data: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: """ - 接收文件。 - + 接收文件 + + 处理前端上传的文件,下载文件并保存到指定目录。 + 此方法用于处理前端上传的文件,下载文件并保存到指定目录。主要步骤包括: 1. 处理前端传递的数据,获取文件的URL。 2. 解析URL以获取文件名。 @@ -147,7 +178,19 @@ class F6PluginModule: @staticmethod - def create_brand(data: Dict[str, Any]) -> Dict[str, str]: # 创建品牌 + def create_brand(data: Dict[str, Any]) -> Dict[str, str]: + """ + 创建品牌 + + 从简道云获取品牌创建请求,读取 Excel 文件,并在后台线程中批量创建品牌。 + 立即返回"正在执行"的提示,实际创建在后台线程中执行。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'} + """ entry_data = api_instance.entry_data_get(data=data) print('执行 品牌批量新建') username = entry_data['data']['账号'] @@ -167,7 +210,7 @@ class F6PluginModule: cookies = requests.utils.dict_from_cookiejar(login_response.cookies) try: - thread = threading.Thread(target=back_ground_tasks.create_brand_background, + thread = threading.Thread(target=create_brand_background, args=(data, cookies, df, save_path)) thread.start() except Exception as e: @@ -177,6 +220,18 @@ class F6PluginModule: @staticmethod def delete_history(data: Dict[str, Any]) -> Dict[str, str]: + """ + 删除历史记录 + + 从简道云获取删除历史记录请求,在后台线程中删除指定门店的历史维修记录。 + 立即返回"正在执行中"的提示,实际删除在后台线程中执行。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典 + """ entry_data = api_instance.entry_data_get(data=data) username = entry_data['data']['账号'] password = entry_data['data']['密码'] @@ -202,7 +257,7 @@ class F6PluginModule: org_id = org['orgId'] if org_id: - thread = threading.Thread(target=back_ground_tasks.delete_history_background, + thread = threading.Thread(target=delete_history_background, args=(data, cookies, org_id, org_name1)) thread.start() return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'} @@ -211,6 +266,18 @@ class F6PluginModule: @staticmethod def delete_customer(data): + """ + 删除客户 + + 从简道云获取删除客户请求,在后台线程中批量删除客户信息。 + 立即返回"正在执行中"的提示,实际删除在后台线程中执行。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典 + """ entry_data = api_instance.entry_data_get(data=data) username = entry_data['data']['账号'] password = entry_data['data']['密码'] @@ -225,7 +292,7 @@ class F6PluginModule: json = res.json() if json: - thread = threading.Thread(target=back_ground_tasks.delete_customer_background, + thread = threading.Thread(target=delete_customer_background, args=(data, cookies, json['data']['data'],)) thread.start() return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'} @@ -236,6 +303,18 @@ class F6PluginModule: @staticmethod def delete_cars(data): + """ + 删除车辆 + + 从简道云获取删除车辆请求,在后台线程中批量删除客户车辆信息。 + 立即返回"正在执行中"的提示,实际删除在后台线程中执行。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典 + """ entry_data = api_instance.entry_data_get(data=data) username = entry_data['data']['账号'] password = entry_data['data']['密码'] @@ -259,7 +338,7 @@ class F6PluginModule: all_page = total_items // 100 + (total_items % 100 > 0) if res_data: - thread = threading.Thread(target=back_ground_tasks.delete_car_background, + thread = threading.Thread(target=delete_car_background, args=(data, url, cookies, header, all_page)) thread.start() return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'} @@ -270,6 +349,18 @@ class F6PluginModule: return {'msg': '未执行', 'msg_details': '登录失败'} def modify_customer_info(self, data: Dict[str, str]): + """ + 修改客户信息 + + 从简道云获取修改客户信息请求,读取 Excel 文件,并在后台线程中批量修改客户信息。 + 立即返回"正在执行中"的提示,实际修改在后台线程中执行。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典 + """ entry_data = api_instance.entry_data_get(data=data) username = entry_data['data']['账号'] password = entry_data['data']['密码'] @@ -288,11 +379,61 @@ class F6PluginModule: return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'} if cookies: - thread = threading.Thread(target=back_ground_tasks.modify_customer_info_background, + thread = threading.Thread(target=modify_customer_info_background, args=(data, cookies, df, save_path)) thread.start() return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'} else: return {'msg': '未执行', 'msg_details': 'cookies获取失败'} + @staticmethod + def bi_task(data: Dict[str, Any]) -> Dict[str, str]: + """ + BI任务 + + 从简道云获取BI任务请求,读取 Excel 文件(如果需要),并在后台线程中执行BI任务。 + 立即返回"正在执行"的提示,实际执行在后台线程中完成。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'} + """ + entry_data = api_instance.entry_data_get(data=data) + print('执行 BI任务') + + # 获取必要的参数(根据实际需求调整) + username = entry_data['data'].get('账号') + password = entry_data['data'].get('密码') + company_name = entry_data['data'].get('公司名称') + save_path = entry_data['data'].get('文件保存地址') + + # 如果需要登录F6系统 + cookies = None + if username and password and company_name: + login_response = F6Module.login_in(username, password, company_name) + if login_response is None: + return {'msg': '登录失败', 'msg_details': '无法登录F6系统'} + cookies = requests.utils.dict_from_cookiejar(login_response.cookies) + + # 如果需要读取Excel文件 + df = None + if save_path: + try: + df = pd.read_excel(save_path, sheet_name=0, dtype='string') + except Exception as e: + return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'} + + # 启动后台线程执行BI任务 + try: + thread = threading.Thread(target=bi_task_background, + args=(data, cookies, df, save_path)) + thread.start() + except Exception as e: + print(f'创建线程失败: {str(e)}') + return {'msg': '任务启动失败', 'msg_details': f'无法启动后台任务: {str(e)}'} + + return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'} + diff --git a/app/module/__init__.py b/app/module/__init__.py index fe16459..c3603e9 100644 --- a/app/module/__init__.py +++ b/app/module/__init__.py @@ -1,2 +1,10 @@ +""" +业务模块包 + +本包包含所有业务模块,包括: +- module.py: F6Module - F6系统相关功能 +- f6_plugin_module.py: F6PluginModule - F6插件功能 +- other_module.py: OtherPluginModule - 其他功能模块 +""" __all__ = [] diff --git a/app/module/module.py b/app/module/module.py index 11b5f1e..67f7613 100644 --- a/app/module/module.py +++ b/app/module/module.py @@ -1,3 +1,18 @@ +""" +F6 系统模块 + +本模块提供 F6 系统相关的功能,包括: +- 登录和认证 +- 验证码识别 +- 公司信息获取 +- 门店信息获取 +- 保持连接 + +依赖: +- requests: HTTP 请求 +- PIL: 图像处理 +- pytesseract: OCR 识别 +""" import requests import hashlib from urllib.parse import quote @@ -7,15 +22,33 @@ from typing import Optional, Dict, AnyStr from PIL import Image, ImageEnhance import pytesseract import logging -from datetime import datetime +# 简道云 API 实例,用于调用简道云 API api_instance = API() + +# 日志记录器 logger = logging.getLogger('app') class F6Module: + """ + F6 系统模块类 + + 提供 F6 系统相关的所有功能,包括登录、信息获取等。 + """ @staticmethod def get_captcha() -> AnyStr: + """ + 获取并识别验证码 + + 从 F6 系统获取验证码图片,使用 OCR 识别验证码文本。 + + Returns: + AnyStr: 识别出的验证码文本 + + 注意: + 需要系统安装 Tesseract OCR 才能正常工作 + """ captcha_url = 'https://yunxiu.f6car.cn/kzf6/login/captcha-image' response = requests.get(captcha_url) with open('captcha.png', 'wb') as f: @@ -37,6 +70,23 @@ class F6Module: @staticmethod def login_in(username: str, password: str, company_name: str = '默认门店',) -> Optional[requests.Response]: + """ + F6 系统登录 + + 使用用户名和密码登录 F6 系统,并选择指定的公司。 + 如果触发验证码,会自动识别并重试登录。 + + Args: + username: 用户名 + password: 密码(明文,方法内部会进行 MD5 加密) + company_name: 公司名称,默认为'默认门店' + + Returns: + Optional[requests.Response]: 登录响应对象,登录失败返回 None + + 注意: + 密码会在方法内部进行 MD5 加密处理 + """ url = "https://yunxiu.f6car.com/kzf6/login/confirm" session = requests.Session() header = { @@ -82,6 +132,17 @@ class F6Module: return None def accept_login_message(self, data: Dict[str, str]) -> Dict[str, str]: + """ + 接受登录消息并处理 + + 处理简道云插件发送的登录请求,执行登录并返回结果。 + + Args: + data: 包含用户名、密码、公司名称的字典 + + Returns: + Dict[str, str]: 登录结果,包含状态信息 + """ username = data['username'] password = data['password'] company_name = data['company_name'] @@ -110,6 +171,17 @@ class F6Module: return {"status": "登录失败,请检查公司名称"} def get_company_information(self, data: Dict[str, str]) -> Dict[str, str]: + """ + 获取公司信息 + + 根据用户名和密码获取 F6 系统中的公司信息,并将结果保存到简道云。 + + Args: + data: 包含用户名、密码的字典 + + Returns: + Dict[str, str]: 包含时间戳的消息,用于后续查询 + """ username = data['username'] password = data['password'] timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") @@ -173,6 +245,18 @@ class F6Module: return res def get_store_information(self, data: Dict[str, str]) -> Dict[str, dict[str, str]]: + """ + 获取门店信息 + + 根据用户名、密码和公司名称获取 F6 系统中的门店信息, + 包括门店列表、客户车辆数量、客户数量等。 + + Args: + data: 包含用户名、密码、公司名称的字典 + + Returns: + Dict[str, dict[str, str]]: 包含时间戳、门店信息、统计数据的结果 + """ username = data['username'] password = data['password'] company_name = data['company_name'] @@ -221,6 +305,17 @@ class F6Module: @staticmethod def get_keep_heart(data: Dict[str, str]) -> Dict[str, str]: + """ + 保持连接 + + 用于保持连接的心跳检测,直接返回接收到的数据。 + + Args: + data: 接收到的数据字典 + + Returns: + Dict[str, str]: 原样返回接收到的数据 + """ return data diff --git a/app/module/other_module.py b/app/module/other_module.py index 03f0e54..c4331c4 100644 --- a/app/module/other_module.py +++ b/app/module/other_module.py @@ -1,22 +1,26 @@ -import requests -from urllib.parse import quote -import pandas as pd -import os -import urllib.parse -from datetime import datetime -from app.api import API -from typing import Optional, Dict, Any, Tuple -from app.config import Config -from app.module import F6Module -import threading -from app import back_ground_tasks +""" +其他插件模块 -api_instance = API() +本模块提供其他插件相关的功能,目前包括短信签名状态查询等功能。 +""" class OtherPluginModule: - + """ + 其他插件模块类 + + 提供其他插件相关的功能,如短信签名状态等。 + """ + def sms_signature_status(self): + """ + 短信签名状态 + + 查询短信签名状态(待实现)。 + + Returns: + 待实现 + """ pass diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..ba77a64 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,77 @@ +""" +Pydantic 数据模型定义 +用于 FastAPI 请求和响应的数据验证 +""" +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field +from enum import Enum + + +class ActionType(str, Enum): + """支持的操作类型枚举""" + LOGIN_IN = "login_in" + GET_COMPANY_INFORMATION = "get_company_information" + GET_STORE_INFORMATION = "get_store_information" + KEEP_ALIVE = "keep_alive" + CHECK_FILE = "check_file" + CREATE_BRAND = "create_brand" + DELETE_HISTORY = "delete_history" + DELETE_CUSTOMER = "delete_customer" + DELETE_CARS = "delete_cars" + SMS_SIGNATURE_STATUS = "sms_signature_status" + MODIFY_CUSTOMER_INFO = "modify_customer_info" + F6_PLUGIN = "F6_Plugin" + + +class WebhookRequest(BaseModel): + """Webhook 请求体数据模型""" + # 通用字段 + api_key: Optional[str] = Field(None, description="简道云应用ID") + entry_id: Optional[str] = Field(None, description="简道云表单ID") + data_id: Optional[str] = Field(None, description="简道云数据ID") + Action: Optional[str] = Field(None, description="操作类型") + + # 登录相关字段 + username: Optional[str] = Field(None, description="用户名") + password: Optional[str] = Field(None, description="密码") + company_name: Optional[str] = Field(None, description="公司名称") + + # 文件相关字段 + file_path: Optional[str] = Field(None, description="文件保存路径") + + # 其他字段(允许任意额外字段) + class Config: + extra = "allow" # 允许额外字段,因为简道云可能传递其他字段 + + +class WebhookHeader(BaseModel): + """Webhook 请求头数据模型""" + Action: Optional[str] = Field(None, description="操作类型") + Check: Optional[str] = Field(None, description="检查标志(是/否)") + + class Config: + extra = "allow" # 允许额外请求头 + + +class WebhookResponse(BaseModel): + """Webhook 响应数据模型""" + msg: str = Field(..., description="响应消息") + msg_details: Optional[str] = Field(None, description="详细信息") + check: Optional[str] = Field(None, description="检查结果") + status: Optional[str] = Field(None, description="状态") + + class Config: + extra = "allow" # 允许额外字段 + + +class HealthResponse(BaseModel): + """健康检查响应模型""" + status: str = Field("ok", description="服务状态") + version: Optional[str] = Field(None, description="服务版本") + + +class ErrorResponse(BaseModel): + """错误响应模型""" + detail: str = Field(..., description="错误详情") + error_code: Optional[str] = Field(None, description="错误代码") + diff --git a/app/tasks/README.md b/app/tasks/README.md deleted file mode 100644 index b65f499..0000000 --- a/app/tasks/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# 后台任务模块结构说明 - -## 模块结构 - -后台任务已按功能拆分为以下模块: - -``` -app/tasks/ -├── __init__.py # 统一导出入口 -├── common.py # 通用功能模块(简道云表单更新、工作流审批) -├── brand_tasks.py # 品牌相关任务 -├── delete_tasks.py # 删除相关任务 -└── customer_tasks.py # 客户相关任务 -``` - -## 模块说明 - -### common.py - 通用功能模块 -包含所有任务共用的功能: -- `update_jiandaoyun()` - 更新简道云表单 -- `approve_workflow()` - 工作流审批 - -### brand_tasks.py - 品牌任务模块 -品牌相关的后台任务: -- `create_brand_background()` - 品牌批量创建 - -### delete_tasks.py - 删除任务模块 -删除相关的后台任务: -- `delete_history_background()` - 删除历史维修记录 -- `delete_customer_background()` - 删除客户信息 -- `delete_car_background()` - 删除客户车辆信息 - -### customer_tasks.py - 客户任务模块 -客户相关的后台任务: -- `modify_customer_info_background()` - 修改客户信息 - -## 向后兼容 - -原有的 `app.back_ground_tasks` 模块仍然可用,它现在作为向后兼容的入口,实际功能已拆分到 `app.tasks` 模块中。 - -## 添加新功能模块 - -如需添加新的功能模块,请按以下步骤: - -1. 在 `app/tasks/` 目录下创建新的模块文件,例如 `new_feature_tasks.py` -2. 在新模块中实现相关功能函数 -3. 在 `app/tasks/__init__.py` 中导入并导出新函数 -4. 在 `app/back_ground_tasks.py` 中导入新函数以保持向后兼容 - -示例: - -```python -# app/tasks/new_feature_tasks.py -from app.tasks.common import update_jiandaoyun, approve_workflow - -def new_feature_background(data, cookies): - # 实现新功能 - result = "执行结果" - msg = update_jiandaoyun(data, result) - if msg.get('msg'): - approve_workflow(data) -``` - -```python -# app/tasks/__init__.py 中添加 -from app.tasks.new_feature_tasks import new_feature_background - -__all__ = [ - # ... 其他函数 - 'new_feature_background', -] -``` - -## 使用方式 - -### 方式一:使用新的模块结构(推荐) -```python -from app.tasks import create_brand_background -from app.tasks.brand_tasks import create_brand_background # 也可以直接导入 -``` - -### 方式二:使用向后兼容的导入方式 -```python -from app import back_ground_tasks -back_ground_tasks.create_brand_background(...) -``` - -两种方式都可以正常工作,代码执行逻辑完全一致。 - diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py index e942da5..03546e5 100644 --- a/app/tasks/__init__.py +++ b/app/tasks/__init__.py @@ -1,6 +1,14 @@ """ 后台任务模块统一导出入口 -保持向后兼容,所有原有导入方式仍然有效 + +本模块统一导出所有后台任务函数,保持向后兼容。 +所有原有导入方式仍然有效。 + +导出的任务包括: +- 通用功能: update_jiandaoyun, approve_workflow +- 品牌任务: create_brand_background +- 删除任务: delete_history_background, delete_customer_background, delete_car_background +- 客户任务: modify_customer_info_background """ # 通用功能 from app.tasks.common import update_jiandaoyun, approve_workflow @@ -18,6 +26,9 @@ from app.tasks.delete_tasks import ( # 客户相关任务 from app.tasks.customer_tasks import modify_customer_info_background +# BI相关任务 +from app.tasks.bi_tasks import bi_task_background + __all__ = [ # 通用功能 'update_jiandaoyun', @@ -30,5 +41,7 @@ __all__ = [ 'delete_car_background', # 客户任务 'modify_customer_info_background', + # BI任务 + 'bi_task_background', ] diff --git a/app/tasks/bi_tasks.py b/app/tasks/bi_tasks.py new file mode 100644 index 0000000..432cec3 --- /dev/null +++ b/app/tasks/bi_tasks.py @@ -0,0 +1,86 @@ +""" +BI相关后台任务模块 + +本模块包含BI相关的后台任务,包括: +- BI数据处理 +- BI报表生成 + +这些任务在后台线程中执行,不会阻塞主请求。 +执行完成后会更新简道云表单并自动提交工作流。 +""" +import logging +import os +import requests +import pandas as pd +from typing import Dict, Any +from tqdm import tqdm +from app.tasks.common import update_jiandaoyun, approve_workflow + +logger = logging.getLogger('app') + + +def bi_task_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame = None, save_path: str = None): + """ + BI任务后台执行函数 + + 在后台线程中执行BI相关任务,如数据处理、报表生成等。 + 执行完成后会更新简道云表单并自动提交工作流。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + cookies: 用户登录 F6 系统的 cookies 信息(如果需要) + df: Excel 文件读取的内容,DataFrame 格式(如果需要) + save_path: Excel 文件保存的地址,执行完成后会删除此文件(如果需要) + + Returns: + None + + 注意: + - 这是一个示例函数,需要根据实际BI任务需求进行实现 + - 执行完成后会自动删除上传的文件(如果提供了save_path) + - 执行结果会更新到简道云表单 + """ + try: + # TODO: 在这里实现具体的BI任务逻辑 + # 例如:数据处理、报表生成、数据同步等 + + # 示例:处理数据 + results = [] + if df is not None: + df = df.where(pd.notnull(df), None) + for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理BI数据"): + # TODO: 实现具体的数据处理逻辑 + # 例如:调用BI API、生成报表、数据转换等 + result_item = { + '行号': index + 1, + '状态': '处理成功' + } + results.append(result_item) + else: + # 如果没有DataFrame,执行其他BI任务 + # TODO: 实现其他BI任务逻辑 + results.append({'状态': 'BI任务执行成功'}) + + # 删除文件(如果提供了save_path) + if save_path and os.path.exists(save_path): + os.remove(save_path) + logger.info(f'{save_path}已删除') + + # 格式化结果 + results_str = f'{results}' if results else 'BI任务执行完成' + logger.info(f"BI任务执行结果: {results_str}") + + # 调用api回写改掉 执行明细与执行状态 + msg = update_jiandaoyun(data, results_str) + + if msg.get('msg'): + approve_workflow(data) + logger.info('表单已自动提交至下一步') + + except Exception as e: + error_msg = f'BI任务执行失败: {str(e)}' + logger.error(error_msg, exc_info=True) + msg = update_jiandaoyun(data, error_msg) + if msg.get('msg'): + approve_workflow(data) + diff --git a/app/tasks/brand_tasks.py b/app/tasks/brand_tasks.py index adb6719..ee8d4b0 100644 --- a/app/tasks/brand_tasks.py +++ b/app/tasks/brand_tasks.py @@ -1,6 +1,10 @@ """ 品牌相关后台任务模块 -包含品牌批量创建等功能 + +本模块包含品牌相关的后台任务,包括: +- 品牌批量创建 + +这些任务在后台线程中执行,不会阻塞主请求。 """ import logging import os @@ -15,12 +19,24 @@ logger = logging.getLogger('app') def create_brand_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str): """ - 品牌批量创建后台运行函数 - :param data: 包含表单id、数据id等的字典 - :param cookies: 用户登录f6系统的cookies信息 - :param df: 表格读取到的内容,DataFrame格式 - :param save_path: 文件保存的地址 - :return: None + 品牌批量创建后台任务 + + 在后台线程中批量创建品牌,从 Excel 文件中读取品牌名称并创建。 + 执行完成后会更新简道云表单并自动提交工作流。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + cookies: 用户登录 F6 系统的 cookies 信息 + df: Excel 文件读取的内容,DataFrame 格式,第一列为品牌名称 + save_path: Excel 文件保存的地址,执行完成后会删除此文件 + + Returns: + None + + 注意: + - 无效的品牌名(None、空字符串)会被跳过 + - 执行完成后会自动删除上传的文件 + - 执行结果会更新到简道云表单 """ df = df.where(pd.notnull(df), None) # 定义请求URL diff --git a/app/tasks/common.py b/app/tasks/common.py index 1977b26..b283860 100644 --- a/app/tasks/common.py +++ b/app/tasks/common.py @@ -1,10 +1,19 @@ """ 通用后台任务模块 -包含简道云表单更新和工作流审批等通用功能 + +本模块包含所有后台任务通用的功能,包括: +- 简道云表单更新 +- 工作流审批 +- 获取门店ID +- 获取会员卡列表 + +这些功能被多个后台任务模块复用。 """ import logging import time -from typing import Dict, Any +import requests +from typing import Dict, Any, List, Optional, Callable +from tqdm import tqdm from app.api import API api_instance = API() @@ -14,9 +23,15 @@ logger = logging.getLogger('app') def update_jiandaoyun(data: Dict[str, Any], results: str): """ 更新简道云表单 - :param data: 包含表单id、应用id、数据id的字典 - :param results: 执行结果信息 - :return: 更新结果字典 + + 将后台任务的执行结果更新到简道云表单中。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + results: 执行结果信息,将写入到表单的执行明细字段 + + Returns: + Dict: 更新结果字典,{'msg': True} 表示成功,{'msg': False} 表示失败 """ # 定义简道云数据配置 jiandaoyun_data = { @@ -44,8 +59,17 @@ def update_jiandaoyun(data: Dict[str, Any], results: str): def approve_workflow(data: Dict[str, Any]): """ 获取简道云当前流程节点并直接提交 - :param data: 包含表单id、应用id、数据id的字典 - :return: None + + 获取简道云工作流的当前待处理任务,并自动提交到下一步。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + + Returns: + None + + 注意: + 如果未找到待处理任务,函数会记录错误并返回,不会抛出异常 """ # 获取简道云当前流程列表 json = api_instance.workflow_instance_get(data) @@ -92,3 +116,108 @@ def approve_workflow(data: Dict[str, Any]): except Exception as e: logger.error(f"简道云工作流任务提交失败: {e}") + +def get_operate_org_id(cookies: Dict[str, str]) -> Optional[str]: + """ + 获取操作门店ID + + 从F6系统获取第一个门店的组织ID,用于后续操作。 + + Args: + cookies: 用户登录 F6 系统的 cookies 信息 + + Returns: + Optional[str]: 门店ID,如果获取失败返回 None + + 注意: + 如果未获取到门店信息或门店ID为空,会记录错误日志并返回 None + """ + org_url = "https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=10&name=" + + try: + org_res = requests.get(url=org_url, cookies=cookies) + org_data = org_res.json().get("data", {}) + org_list = org_data.get("list", []) + + if not org_list or len(org_list) == 0: + logger.error("未获取到门店信息") + return None + + operate_org_id = org_list[0].get("orgId") + if not operate_org_id: + logger.error("门店ID为空") + return None + + logger.info(f"获取门店ID成功: {operate_org_id}") + return operate_org_id + except Exception as e: + logger.error(f"获取门店ID时发生错误: {e}") + return None + + +def get_card_list( + cookies: Dict[str, str], + operate_org_id: str, + extract_func: Callable[[Dict], Optional[str]] = None +) -> List[str]: + """ + 获取会员卡列表 + + 从F6系统获取指定门店的会员卡列表,支持自定义提取逻辑。 + + Args: + cookies: 用户登录 F6 系统的 cookies 信息 + operate_org_id: 门店ID + extract_func: 自定义提取函数,用于从会员卡数据中提取ID + 如果不提供,默认提取 idCustomer 字段 + + Returns: + List[str]: 会员卡ID列表 + + 注意: + - 默认每页100条数据,会自动分页获取所有数据 + - 每页请求间隔0.2秒,避免请求过快 + """ + card_list = [] + + try: + # 获取第一页,确定总页数 + card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1" + card_res = requests.get(url=card_url, cookies=cookies) + total_card = int(card_res.json().get("data", {}).get("total", 0)) + + if total_card == 0: + logger.info("未找到会员卡数据") + return card_list + + total_page = total_card // 100 + (total_card % 100 > 0) + logger.info(f"会员卡总数: {total_card}, 总页数: {total_page}") + + # 定义默认提取函数(提取客户ID) + if extract_func is None: + def default_extract(card_item: Dict) -> Optional[str]: + return card_item.get("idCustomer") + extract_func = default_extract + + # 分页获取所有会员卡数据 + for page in tqdm(range(1, total_page + 1), desc="查询会员卡"): + card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}" + f"&pageSize=100&pageNo={page}") + card_res = requests.get(url=card_url, cookies=cookies) + card_data_list = card_res.json().get("data", {}).get("data", []) + + # 使用提取函数提取ID + for card_item in card_data_list: + extracted_id = extract_func(card_item) + if extracted_id is not None: + card_list.append(extracted_id) + + time.sleep(0.2) + + logger.info(f"获取会员卡列表成功,共 {len(card_list)} 条") + return card_list + + except Exception as e: + logger.error(f"获取会员卡列表时发生错误: {e}") + return card_list + diff --git a/app/tasks/customer_tasks.py b/app/tasks/customer_tasks.py index 613ee4b..89bef75 100644 --- a/app/tasks/customer_tasks.py +++ b/app/tasks/customer_tasks.py @@ -1,6 +1,10 @@ """ 客户相关后台任务模块 -包含修改客户信息等功能 + +本模块包含客户相关的后台任务,包括: +- 客户信息批量修改 + +这些任务在后台线程中执行,不会阻塞主请求。 """ import logging import requests @@ -15,16 +19,24 @@ logger = logging.getLogger('app') def modify_customer_info_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str): """ - 修改客户信息后台任务。 - - 此函数用于后台任务,用于修改会员信息。 - + 修改客户信息后台任务 + + 在后台线程中批量修改客户信息,从 Excel 文件中读取客户手机号和修改信息。 + 执行完成后会更新简道云表单并自动提交工作流。 + Args: - data (Dict[str, Any]): 前端请求发送过来的数据,包含文件信息和其他必要参数。 - cookies (Dict[str, str]): 登录用户的Cookies。 - + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + cookies: 用户登录 F6 系统的 cookies 信息 + df: Excel 文件读取的内容,DataFrame 格式,第一列为客户手机号 + save_path: Excel 文件保存的地址,执行完成后会删除此文件 + Returns: None + + 注意: + - 根据客户手机号匹配客户信息 + - 执行完成后会自动删除上传的文件 + - 执行结果会更新到简道云表单 """ df = df.where(pd.notnull(df), None) params = { diff --git a/app/tasks/delete_tasks.py b/app/tasks/delete_tasks.py index 2b00130..a7f0e64 100644 --- a/app/tasks/delete_tasks.py +++ b/app/tasks/delete_tasks.py @@ -1,27 +1,41 @@ """ 删除相关后台任务模块 -包含删除历史维修记录、删除客户信息、删除客户车辆信息等功能 + +本模块包含删除相关的后台任务,包括: +- 删除历史维修记录 +- 删除客户信息 +- 删除客户车辆信息 + +这些任务在后台线程中执行,不会阻塞主请求。 +执行完成后会更新简道云表单并自动提交工作流。 """ import logging import traceback import requests import time -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional from datetime import datetime from tqdm import tqdm -from app.tasks.common import update_jiandaoyun, approve_workflow +from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id, get_card_list logger = logging.getLogger('app') def delete_history_background(data: Dict[str, Any], cookies: Dict[str, str], org_id: str, org_name: str): """ - 删除历史维修数据后台运行函数 - :param data: 包含表单id、数据id等的字典 - :param cookies: 用户登录F6系统的cookies信息 - :param org_id: 需要删除历史维修记录的门店id - :param org_name: 需要删除历史维修记录的门店名称 - :return: None + 删除历史维修记录后台任务 + + 在后台线程中删除指定门店的历史维修记录。 + 执行完成后会更新简道云表单并自动提交工作流。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + cookies: 用户登录 F6 系统的 cookies 信息 + org_id: 需要删除历史维修记录的门店ID + org_name: 需要删除历史维修记录的门店名称 + + Returns: + None """ url = f'https://yunxiu.f6car.cn/maintain-dump/maintainHistory/?orgid={org_id}' # 删除url res = requests.delete(url=url, cookies=cookies) @@ -47,60 +61,37 @@ def delete_history_background(data: Dict[str, Any], cookies: Dict[str, str], org def delete_customer_background(data: Dict[str, Any], cookies: Dict[str, str], json_data: List[Dict[str, Any]]): """ - 删除客户信息后台运行函数 - :param data: 包含表单id、数据id等字典 - :param cookies: 用户登录f6系统的cookies信息 - :param json_data: 获取到的客户信息列表,列表最大值取决url里面的值 - :return: None + 删除客户信息后台任务 + + 在后台线程中批量删除客户信息。 + 执行完成后会更新简道云表单并自动提交工作流。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + cookies: 用户登录 F6 系统的 cookies 信息 + json_data: 获取到的客户信息列表,包含要删除的客户信息 + + Returns: + None + + 注意: + - 8-20点之间每3.5秒删除一条数据,其余时间每1.5秒删除一条数据 + - 执行结果会更新到简道云表单 """ success = 0 fail = 0 # 获取门店ID - org_url = "https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=10&name=" - org_res = requests.get(url=org_url, cookies=cookies) - - # 安全地获取门店ID - org_data = org_res.json().get("data", {}) - org_list = org_data.get("list", []) - - if not org_list or len(org_list) == 0: - logger.error("未获取到门店信息") + operate_org_id = get_operate_org_id(cookies) + if not operate_org_id: msg = update_jiandaoyun(data, '删除失败: 未获取到门店信息') if msg.get('msg'): approve_workflow(data) return - operate_org_id = org_list[0].get("orgId") - if not operate_org_id: - logger.error("门店ID为空") - msg = update_jiandaoyun(data, '删除失败: 门店ID为空') - if msg.get('msg'): - approve_workflow(data) - return - print(operate_org_id) - # 获取会员卡列表 - card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1" - - card_res = requests.get(url=card_url, cookies=cookies) - total_card = int(card_res.json().get("data").get("total")) - print(total_card) - total_page = total_card // 100 + (total_card % 100 > 0) - card_list_customers = [] - for page in tqdm(range(1, total_page + 1), desc="查询会员卡"): - card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}" - f"&pageSize=100&pageNo={page}") - - card_res = requests.get(url=card_url, cookies=cookies) - card_cars_list = card_res.json().get("data").get("data") - - for card_customer in card_cars_list: - if card_customer.get("idCustomer") is None: - continue - else: - card_list_customers.append(card_customer.get("idCustomer", None)) - time.sleep(0.2) + # 获取会员卡列表(提取客户ID) + card_list_customers = get_card_list(cookies, operate_org_id) for item in tqdm(json_data, desc="删除客户"): id_customer = item['idCustomer'] @@ -135,10 +126,10 @@ def delete_customer_background(data: Dict[str, Any], cookies: Dict[str, str], js continue now = datetime.now() - if 20 <= now.hour <= 8: - time.sleep(1) + if 8 <= now.hour <= 20: + time.sleep(3.5) else: - time.sleep(3) + time.sleep(1.5) logger.info(f"客户删除结果: 成功次数={success}, 失败次数={fail}") @@ -152,13 +143,26 @@ def delete_customer_background(data: Dict[str, Any], cookies: Dict[str, str], js def delete_car_background(data: Dict[str, Any], url: str, cookies: Dict[str, str], header: Dict[str, Any], all_page: str): """ - 删除客户车辆信息后台运行函数 - :param header: 应包含账号登录的请求头 - :param url: 包含请求客户车辆信息的url - :param all_page: 客户车辆信息的页数 - :param data: 包含表单id、数据id等的字典 - :param cookies: 登录F6系统后的请求信息 - :return: None + 删除客户车辆信息后台任务 + + 在后台线程中批量删除客户车辆信息。 + 会检查车辆是否有会员卡或最近消费记录,有则跳过删除。 + 执行完成后会更新简道云表单并自动提交工作流。 + + Args: + data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 + url: 获取车辆列表的 API URL + cookies: 用户登录 F6 系统的 cookies 信息 + header: HTTP 请求头字典,应包含账号登录的请求头 + all_page: 总页数(字符串或整数),用于分页获取车辆列表 + + Returns: + None + + 注意: + - 8-20点之间每3.5秒删除一条数据,其余时间每1.5秒删除一条数据 + - 有会员卡或最近消费记录的车辆会被跳过 + - 执行结果会更新到简道云表单 """ print(cookies) success = 0 @@ -168,50 +172,42 @@ def delete_car_background(data: Dict[str, Any], url: str, cookies: Dict[str, str all_page = int(all_page) # 获取门店ID - org_url = "https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=10&name=" - org_res = requests.get(url=org_url, cookies=cookies) - - # 安全地获取门店ID - org_data = org_res.json().get("data", {}) - org_list = org_data.get("list", []) - - if not org_list or len(org_list) == 0: - logger.error("未获取到门店信息") + operate_org_id = get_operate_org_id(cookies) + if not operate_org_id: msg = update_jiandaoyun(data, '删除失败: 未获取到门店信息') if msg.get('msg'): approve_workflow(data) return - operate_org_id = org_list[0].get("orgId") - if not operate_org_id: - logger.error("门店ID为空") - msg = update_jiandaoyun(data, '删除失败: 门店ID为空') - if msg.get('msg'): - approve_workflow(data) - return - print(operate_org_id) - # 获取会员卡列表 - card_url = ( - f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1" - ) - card_res = requests.get(url=card_url, cookies=cookies) - total_card = int(card_res.json().get("data").get("total")) - print(total_card) - total_page = total_card // 100 + (total_card % 100 > 0) + # 获取会员卡列表(提取车辆ID) + # 注意:需要获取所有车辆的ID,所以不能直接使用 get_card_list + # 需要自定义提取逻辑,返回所有车辆的ID列表 card_list_cars = [] - for page in tqdm(range(1, total_page + 1), desc="查询会员卡"): - card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}" - f"&pageSize=100&pageNo={page}") + try: + # 获取第一页,确定总页数 + card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1" card_res = requests.get(url=card_url, cookies=cookies) - card_cars_list = card_res.json().get("data").get("data") - - for card_car in card_cars_list: - if card_car.get("cars") is None: - continue - for car in card_car.get("cars", []): - card_list_cars.append(car.get("idCar", None)) - time.sleep(0.2) + total_card = int(card_res.json().get("data", {}).get("total", 0)) + + if total_card > 0: + total_page = total_card // 100 + (total_card % 100 > 0) + for page in tqdm(range(1, total_page + 1), desc="查询会员卡"): + card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}" + f"&pageSize=100&pageNo={page}") + card_res = requests.get(url=card_url, cookies=cookies) + card_cars_list = card_res.json().get("data", {}).get("data", []) + + for card_car in card_cars_list: + if card_car.get("cars") is None: + continue + for car in card_car.get("cars", []): + car_id = car.get("idCar") + if car_id: + card_list_cars.append(car_id) + time.sleep(0.2) + except Exception as e: + logger.error(f"获取会员卡列表时发生错误: {e}") itemlist = [] # 使用 range() 创建一个可迭代的对象 diff --git a/app/utils/app_tools.py b/app/utils/app_tools.py index 4c71652..7f95d21 100644 --- a/app/utils/app_tools.py +++ b/app/utils/app_tools.py @@ -1,3 +1,14 @@ +""" +应用工具模块 + +本模块提供应用级的工具类,包括: +- 日志记录器配置(支持日志轮转) +- 后台任务调度器 +- 任务队列管理 +- 请求头解码工具 + +这些工具在整个应用中被广泛使用。 +""" import logging import os from logging.handlers import RotatingFileHandler @@ -8,7 +19,27 @@ from urllib.parse import unquote class AppTools: + """ + 应用级工具集合类 + + 提供应用级别的工具功能,包括: + - 初始化轮转日志记录器 + - 初始化后台调度器(进程退出时自动关闭) + - 维护一个简单的任务队列与后台处理线程 + + 属性: + config: 配置对象 + task_queue: 任务队列 + logger: 日志记录器 + scheduler: 后台调度器 + """ def __init__(self, config): + """ + 初始化应用工具 + + Args: + config: 配置对象,包含日志、目录等配置信息 + """ self.config = config self.task_queue = Queue() self.logger = self._setup_logger() @@ -16,6 +47,19 @@ class AppTools: self._start_task_thread() def _setup_logger(self): + """ + 配置带轮转的文件日志记录器 + + 创建支持日志轮转的文件日志记录器,避免重复添加相同文件处理器。 + + Returns: + logging.Logger: 配置好的日志记录器 + + 注意: + - 日志文件最大 5MB + - 保留 5 个备份文件 + - 使用 UTF-8 编码 + """ log_dir = self.config.LOGS_DIRECTORY if not os.path.exists(log_dir): os.makedirs(log_dir) @@ -25,6 +69,7 @@ class AppTools: logger = logging.getLogger("app") logger.setLevel(logging.INFO) + # 若未绑定目标日志文件的 RotatingFileHandler,则创建并绑定 if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(log_file) for h in logger.handlers): file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024 * 5, backupCount=5, encoding='utf-8') @@ -36,19 +81,43 @@ class AppTools: return logger def _setup_scheduler(self): + """ + 初始化后台调度器 + + 创建后台调度器,并在进程退出时优雅关闭,防止资源泄漏。 + + Returns: + BackgroundScheduler: 后台调度器实例 + + 注意: + 调度器会在进程退出时自动关闭 + """ scheduler = BackgroundScheduler() import atexit atexit.register(lambda: scheduler.shutdown(wait=False)) return scheduler def _start_task_thread(self): + """ + 启动后台任务处理线程 + + 启动一个守护线程来处理任务队列中的任务,守护模式运行,随主线程退出。 + """ task_thread = threading.Thread(target=self.process_tasks, daemon=True) task_thread.start() def process_tasks(self): + """ + 后台消费队列中的任务: + 任务结构为 {'handler': callable, 'data': any, 'response': Queue} + - 正常执行时将 handler(data) 的结果放入 response 队列 + - 发生异常时记录错误并将失败信息放入 response 队列 + - 每次任务完成后调用 task_done() + """ while True: task = self.task_queue.get() if task is None: + # 外部以 None 作为结束信号 self.logger.error("任务处理线程已终止") break try: @@ -62,6 +131,18 @@ class AppTools: self.logger.info("任务处理完成") def enqueue_task(self, handler, data): + """ + 将任务入队 + + 将任务放入任务队列,并返回一个响应队列,调用方可以从响应队列中获取执行结果。 + + Args: + handler: 处理函数,执行具体业务逻辑 + data: 传递给处理函数的数据 + + Returns: + Queue: 响应队列,用于获取任务执行结果 + """ response_queue = Queue() self.task_queue.put({ 'handler': handler, @@ -72,13 +153,39 @@ class AppTools: @staticmethod def decode_headers(headers): + """ + 解码请求头 + + 对请求头字典进行 URL 解码(UTF-8),返回解码后的副本。 + 主要用于处理包含中文字符的请求头。 + + Args: + headers: 请求头字典 + + Returns: + dict: 解码后的请求头字典 + """ return {key: unquote(value, encoding='utf-8') for key, value in headers.items()} +# 全局日志记录器变量 +# 用于存储全局日志记录器实例,避免重复初始化 logger = None def setup_global_logger(config): + """ + 设置全局日志记录器 + + 懒加载并返回全局 logger,若未初始化则构建 AppTools 并复用其中的 logger。 + 避免重复初始化日志处理器。 + + Args: + config: 配置对象 + + Returns: + logging.Logger: 全局日志记录器实例 + """ global logger if logger is None: app_tools = AppTools(config) diff --git a/main.py b/main.py index aa236b9..422dff8 100644 --- a/main.py +++ b/main.py @@ -1,96 +1,201 @@ -from fastapi import FastAPI, Request +""" +简道云 FastAPI 服务 - 主应用入口 + +本文件是 FastAPI 应用的主入口文件,负责: +1. 应用初始化和生命周期管理 +2. 模块注册和路由配置 +3. 异常处理器配置 +4. 中间件配置 + +作者: 项目团队 +版本: 1.0.0 +""" +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request, HTTPException, status from fastapi.responses import JSONResponse -import json -import anyio +from fastapi.exceptions import RequestValidationError +import logging + from app.utils.app_tools import AppTools, setup_global_logger -from app.module.F6_Plugin_module import F6PluginModule +from app.module.f6_plugin_module import F6PluginModule from app.module.module import F6Module from app.module.other_module import OtherPluginModule from app.config import Config +from app.schemas import ErrorResponse +from app.core import core_manager +from app.api.routes import router +from fastapi.middleware.cors import CORSMiddleware -app = FastAPI(title="简道云FastAPI服务") - - -@app.on_event("startup") -def on_startup(): - """应用启动时初始化""" +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理 - 启动和关闭""" + # 启动时初始化 app.state.app_tools = AppTools(Config) app.state.logger = setup_global_logger(Config) - app.state.f6_module = F6Module() - app.state.f6_plugin_module = F6PluginModule() - app.state.other_module = OtherPluginModule() - - -def get_action_map() -> dict: - """获取操作映射表""" - f6_module = app.state.f6_module - f6_plugin_module = app.state.f6_plugin_module - other_module = app.state.other_module - return { - 'login_in': f6_module.accept_login_message, - 'get_company_information': f6_module.get_company_information, - 'get_store_information': f6_module.get_store_information, - "keep_alive": f6_module.get_keep_heart, - 'check_file': f6_plugin_module.check_file, - 'create_brand': f6_plugin_module.create_brand, - 'delete_history': f6_plugin_module.delete_history, - 'delete_customer': f6_plugin_module.delete_customer, - 'delete_cars': f6_plugin_module.delete_cars, - 'sms_signature_status': other_module.sms_signature_status, - 'modify_customer_info': f6_plugin_module.modify_customer_info, - } - - -@app.post("/webhook") -async def webhook(request: Request): - """ - 接受前端请求后将任务放入消息队列 - Returns: - any: 返回任务处理的结果 + # 初始化业务模块 + f6_module = F6Module() + f6_plugin_module = F6PluginModule() + other_module = OtherPluginModule() + + # 将模块实例存储到 app.state(用于依赖注入) + app.state.f6_module = f6_module + app.state.f6_plugin_module = f6_plugin_module + app.state.other_module = other_module + + # 注册模块到 registry + core_manager.initialize_modules({ + 'f6_module': f6_module, + 'f6_plugin_module': f6_plugin_module, + 'other_module': other_module + }) + + # 注册所有操作到 module_registry + core_manager.register_action('login_in', f6_module.accept_login_message, 'f6_module', + description='F6系统登录') + core_manager.register_action('get_company_information', f6_module.get_company_information, 'f6_module', + description='获取公司信息') + core_manager.register_action('get_store_information', f6_module.get_store_information, 'f6_module', + description='获取门店信息') + core_manager.register_action('keep_alive', f6_module.get_keep_heart, 'f6_module', + description='保持连接') + core_manager.register_action('check_file', f6_plugin_module.check_file, 'f6_plugin_module', + description='校验上传文件') + core_manager.register_action('create_brand', f6_plugin_module.create_brand, 'f6_plugin_module', + description='创建品牌') + core_manager.register_action('delete_history', f6_plugin_module.delete_history, 'f6_plugin_module', + description='删除历史记录') + core_manager.register_action('delete_customer', f6_plugin_module.delete_customer, 'f6_plugin_module', + description='删除客户') + core_manager.register_action('delete_cars', f6_plugin_module.delete_cars, 'f6_plugin_module', + description='删除车辆') + core_manager.register_action('sms_signature_status', other_module.sms_signature_status, 'other_module', + description='短信签名状态') + core_manager.register_action('modify_customer_info', f6_plugin_module.modify_customer_info, 'f6_plugin_module', + description='修改客户信息') + core_manager.register_action('bi_task', f6_plugin_module.bi_task, 'f6_plugin_module', + description='BI任务') + + app.state.logger.info("应用启动完成,已注册所有操作") + + yield + + # 关闭时清理资源 + if hasattr(app.state, 'app_tools') and hasattr(app.state.app_tools, 'scheduler'): + app.state.app_tools.scheduler.shutdown(wait=False) + app.state.logger.info("应用关闭") + + +app = FastAPI( + title="简道云FastAPI服务", + description="简道云插件后端服务,提供数据同步和处理功能", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(router) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): """ - logger = app.state.logger - app_tools: AppTools = app.state.app_tools + HTTP 异常处理器 + + 处理所有 HTTPException 异常,返回统一的错误响应格式。 + + Args: + request: FastAPI 请求对象 + exc: HTTPException 异常对象 + + Returns: + JSONResponse: 包含错误详情的 JSON 响应 + """ + logger = getattr(app.state, 'logger', None) + if logger: + logger.error(f"HTTP异常: {exc.status_code} - {exc.detail}") + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse( + detail=exc.detail or "HTTP error", + error_code=f"HTTP_{exc.status_code}" + ).dict(), + ) - # 获取请求数据 - data = await request.json() - header = request.headers - # 解码请求头 - decoded_header = app_tools.decode_headers(header) +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """ + 请求验证异常处理器 + + 处理 Pydantic 数据验证失败的情况,返回详细的验证错误信息。 + + Args: + request: FastAPI 请求对象 + exc: RequestValidationError 异常对象 + + Returns: + JSONResponse: 包含验证错误详情的 JSON 响应 + """ + logger = getattr(app.state, 'logger', None) + if logger: + logger.warning(f"请求验证失败: {exc.errors()}") + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=ErrorResponse( + detail="请求数据验证失败", + error_code="VALIDATION_ERROR" + ).dict(), + ) - # 获取操作映射表 - action_map = get_action_map() - action = decoded_header.get('Action') - # 处理 F6_Plugin 特殊逻辑 - if action == 'F6_Plugin': - check = decoded_header.get('Check') - if check == '否': - handler = app.state.f6_plugin_module.check_file - elif check == '是': - print(data) - sub_action = data.get('Action') - print(sub_action) - handler = action_map.get(sub_action, lambda x: {'msg': '未执行'}) - else: - return JSONResponse({'msg': '未知的操作'}) - else: - handler = action_map.get(action, lambda x: {'msg': '未知的操作'}) +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + """ + 通用异常处理器 + + 捕获所有未处理的异常,防止应用崩溃,并记录详细的错误信息。 + + Args: + request: FastAPI 请求对象 + exc: 异常对象 + + Returns: + JSONResponse: 包含错误详情的 JSON 响应 + """ + logger = getattr(app.state, 'logger', None) + if logger: + logger.error(f"未处理的异常: {type(exc).__name__} - {str(exc)}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=ErrorResponse( + detail="服务器内部错误", + error_code="INTERNAL_ERROR" + ).dict(), + ) - # 将任务放入消息队列 - response_queue = app_tools.enqueue_task(handler, data) - # 等待任务处理结果 - result = await anyio.to_thread.run_sync(response_queue.get) - print(handler) - - logger.info(json.dumps(result, ensure_ascii=False, indent=4)) - - return JSONResponse(result) +# 路由已移动到 app/api/routes.py if __name__ == '__main__': + """ + 直接运行入口 + + 当直接运行此文件时,启动 uvicorn 服务器。 + 默认配置: + - 主机: 0.0.0.0 (监听所有网络接口) + - 端口: 5003 + - 热重载: 关闭 (生产环境建议关闭) + """ import uvicorn - uvicorn.run("fastapi_app.main:app", host="0.0.0.0", port=5003, reload=False) + uvicorn.run(app, host="0.0.0.0", port=5003, reload=False) diff --git a/utils/app_tools.py b/utils/app_tools.py deleted file mode 100644 index 33a448d..0000000 --- a/utils/app_tools.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -import os -from logging.handlers import RotatingFileHandler -from queue import Queue -import threading -from apscheduler.schedulers.background import BackgroundScheduler -from urllib.parse import unquote - - -class AppTools: - def __init__(self, config): - self.config = config - self.task_queue = Queue() - self.logger = self._setup_logger() - self.scheduler = self._setup_scheduler() - self._start_task_thread() - - def _setup_logger(self): - """配置日志记录器(不依赖 Flask)。""" - log_dir = self.config.LOGS_DIRECTORY - if not os.path.exists(log_dir): - os.makedirs(log_dir) - - log_file = self.config.LOG_FILE - - logger = logging.getLogger("app") - logger.setLevel(logging.INFO) - - # 防止重复添加 handler - if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(log_file) - for h in logger.handlers): - file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024 * 5, backupCount=5, encoding='utf-8') - file_handler.setLevel(logging.INFO) - formatter = logging.Formatter('%(asctime)s %(levelname)s:%(name)s:%(message)s') - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - return logger - - def _setup_scheduler(self): - """配置后台调度器""" - scheduler = BackgroundScheduler() - # 如需定时任务可在此添加 - import atexit - atexit.register(lambda: scheduler.shutdown(wait=False)) - return scheduler - - def _start_task_thread(self): - """启动任务处理线程""" - task_thread = threading.Thread(target=self.process_tasks, daemon=True) - task_thread.start() - - def process_tasks(self): - """处理任务队列中的任务""" - while True: - task = self.task_queue.get() - if task is None: - self.logger.error("任务处理线程已终止") - break - try: - result = task['handler'](task['data']) - task['response'].put(result) - except Exception as e: - self.logger.error(f"任务执行失败: {str(e)}") - task['response'].put({'msg': f'任务执行失败: {str(e)}'}) - finally: - self.task_queue.task_done() - self.logger.info("任务处理完成") - - def enqueue_task(self, handler, data): - """将任务放入消息队列""" - response_queue = Queue() - self.task_queue.put({ - 'handler': handler, - 'data': data, - 'response': response_queue - }) - return response_queue - - @staticmethod - def decode_headers(headers): - """解码包含中文字符的 HTTP 请求头""" - return {key: unquote(value, encoding='utf-8') for key, value in headers.items()} - - -# 简易的全局日志记录器设置(与原项目解耦) -logger = None - - -def setup_global_logger(config): - global logger - if logger is None: - app_tools = AppTools(config) - logger = app_tools.logger - return logger - -