From 073f0646a1767c289f82f2210a0856c5ccd378ed Mon Sep 17 00:00:00 2001 From: z66 <1415243231@qq.com> Date: Fri, 7 Nov 2025 17:48:49 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E9=81=93=E4=BA=91fastapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/fastapi_app.iml | 12 + 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 ++++++++++++++++++++++++++ __init__.py | 2 + app/API_UPDATE.md | 186 ++++++++ app/__init__.py | 2 + app/api.py | 793 +++++++++++++++++++++++++++++++++ app/back_ground_tasks.py | 24 + app/config.py | 34 ++ app/core/__init__.py | 60 +++ app/core/header_manager.py | 118 +++++ app/core/module_registry.py | 116 +++++ app/module/F6_Plugin_module.py | 298 +++++++++++++ app/module/__init__.py | 2 + app/module/module.py | 226 ++++++++++ app/module/other_module.py | 22 + app/tasks/README.md | 89 ++++ app/tasks/__init__.py | 34 ++ app/tasks/brand_tasks.py | 55 +++ app/tasks/common.py | 94 ++++ app/tasks/customer_tasks.py | 261 +++++++++++ app/tasks/delete_tasks.py | 304 +++++++++++++ app/utils/app_tools.py | 88 ++++ main.py | 96 ++++ requirements.txt | 11 + utils/app_tools.py | 97 ++++ 30 files changed, 5933 insertions(+) create mode 100644 .idea/fastapi_app.iml create mode 100644 ARCHITECTURE.md create mode 100644 BUG_REPORT.md create mode 100644 FASTAPI_LEARNING.md create mode 100644 FASTAPI_QUICK_REFERENCE.md create mode 100644 FASTAPI_README.md create mode 100644 FLASK_TO_FASTAPI_MIGRATION.md create mode 100644 __init__.py create mode 100644 app/API_UPDATE.md create mode 100644 app/__init__.py create mode 100644 app/api.py create mode 100644 app/back_ground_tasks.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/header_manager.py create mode 100644 app/core/module_registry.py create mode 100644 app/module/F6_Plugin_module.py create mode 100644 app/module/__init__.py create mode 100644 app/module/module.py create mode 100644 app/module/other_module.py create mode 100644 app/tasks/README.md create mode 100644 app/tasks/__init__.py create mode 100644 app/tasks/brand_tasks.py create mode 100644 app/tasks/common.py create mode 100644 app/tasks/customer_tasks.py create mode 100644 app/tasks/delete_tasks.py create mode 100644 app/utils/app_tools.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 utils/app_tools.py diff --git a/.idea/fastapi_app.iml b/.idea/fastapi_app.iml new file mode 100644 index 0000000..b340a3d --- /dev/null +++ b/.idea/fastapi_app.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..6c98e62 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,225 @@ +# 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 new file mode 100644 index 0000000..81f21b2 --- /dev/null +++ b/BUG_REPORT.md @@ -0,0 +1,506 @@ +# 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 new file mode 100644 index 0000000..4f93bec --- /dev/null +++ b/FASTAPI_LEARNING.md @@ -0,0 +1,710 @@ +# 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 new file mode 100644 index 0000000..7fa5e03 --- /dev/null +++ b/FASTAPI_QUICK_REFERENCE.md @@ -0,0 +1,496 @@ +# 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 new file mode 100644 index 0000000..ea2d5a9 --- /dev/null +++ b/FASTAPI_README.md @@ -0,0 +1,352 @@ +# 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 new file mode 100644 index 0000000..b5ec434 --- /dev/null +++ b/FLASK_TO_FASTAPI_MIGRATION.md @@ -0,0 +1,620 @@ +# 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/__init__.py b/__init__.py new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/app/API_UPDATE.md b/app/API_UPDATE.md new file mode 100644 index 0000000..e31d5ba --- /dev/null +++ b/app/API_UPDATE.md @@ -0,0 +1,186 @@ +# 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 new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..4ee8302 --- /dev/null +++ b/app/api.py @@ -0,0 +1,793 @@ +""" +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/back_ground_tasks.py b/app/back_ground_tasks.py new file mode 100644 index 0000000..e9b3420 --- /dev/null +++ b/app/back_ground_tasks.py @@ -0,0 +1,24 @@ +""" +后台任务模块 - 向后兼容入口 +此文件保持向后兼容,实际功能已拆分到 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 new file mode 100644 index 0000000..73fdf11 --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +from pathlib import Path + +# 获取当前文件所在的目录 +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 配置 +JIANDAOYUN_API_TOKEN = 'Bearer qygHulymo1fekJk4CIZyNKjyQAzG8CFN' # 曹伟应用api测试 app_key + + +# 导出配置 +class Config: + BASE_DIR = BASE_DIR + SAVE_DIRECTORY = SAVE_DIRECTORY + MODE_DIRECTORY = MODE_DIRECTORY + JIANDAOYUN_API_TOKEN = JIANDAOYUN_API_TOKEN + LOGS_DIRECTORY = LOGS_DIRECTORY + LOG_FILE = LOG_FILE + + diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..2d0b927 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,60 @@ +""" +核心模块初始化 +统一初始化请求头管理器、模块注册表等核心组件 +""" +from typing import Dict, Any, Callable +from app.core.header_manager import HeaderManager +from app.core.module_registry import ModuleRegistry, registry + + +class CoreManager: + """核心管理器 - 统一管理所有核心组件""" + + def __init__(self): + self.header_manager = HeaderManager + self.registry = registry + + def initialize_modules(self, modules: Dict[str, Any]): + """ + 初始化并注册所有模块 + + Args: + modules: 模块字典,格式为 {模块名: 模块实例} + """ + for module_name, module_instance in modules.items(): + self.registry.register_module(module_name, module_instance) + + def register_action( + self, + action_name: str, + handler: Callable, + module_name: str = "default", + **kwargs + ): + """ + 便捷方法:注册操作 + + Args: + action_name: 操作名称 + handler: 处理函数 + module_name: 模块名称 + **kwargs: 其他配置参数 + """ + self.registry.register_action( + action_name=action_name, + handler=handler, + module_name=module_name, + **kwargs + ) + + +# 全局核心管理器实例 +core_manager = CoreManager() + +# 导出常用类和函数 +__all__ = [ + 'core_manager', + 'HeaderManager', + 'ModuleRegistry', + 'registry', +] diff --git a/app/core/header_manager.py b/app/core/header_manager.py new file mode 100644 index 0000000..0574191 --- /dev/null +++ b/app/core/header_manager.py @@ -0,0 +1,118 @@ +""" +请求头管理器 +统一管理不同模块的请求头配置 +""" +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 new file mode 100644 index 0000000..46af03e --- /dev/null +++ b/app/core/module_registry.py @@ -0,0 +1,116 @@ +""" +模块注册和路由管理 +提供统一的模块注册机制,方便添加新功能模块 +""" +from typing import Dict, Callable, Optional, Any +from dataclasses import dataclass + + +@dataclass +class ActionConfig: + """操作配置""" + handler: Callable # 处理函数 + module_name: str # 所属模块名称 + description: Optional[str] = None # 描述 + requires_auth: bool = True # 是否需要认证 + header_module: Optional[str] = None # 使用的请求头模块名称 + + +class ModuleRegistry: + """模块注册表""" + + def __init__(self): + self._actions: Dict[str, ActionConfig] = {} + self._modules: Dict[str, Dict[str, Any]] = {} + + def register_action( + self, + action_name: str, + handler: Callable, + module_name: str = "default", + description: Optional[str] = None, + requires_auth: bool = True, + header_module: Optional[str] = None + ): + """ + 注册操作 + + Args: + action_name: 操作名称 + handler: 处理函数 + module_name: 模块名称 + description: 描述 + requires_auth: 是否需要认证 + header_module: 请求头模块名称 + """ + config = ActionConfig( + handler=handler, + module_name=module_name, + description=description, + requires_auth=requires_auth, + header_module=header_module + ) + self._actions[action_name] = config + + def get_action(self, action_name: str) -> Optional[ActionConfig]: + """ + 获取操作配置 + + Args: + action_name: 操作名称 + + Returns: + 操作配置,如果不存在返回None + """ + return self._actions.get(action_name) + + def get_all_actions(self) -> Dict[str, ActionConfig]: + """获取所有注册的操作""" + return self._actions.copy() + + def register_module(self, module_name: str, module_instance: Any, **metadata): + """ + 注册模块实例 + + Args: + module_name: 模块名称 + module_instance: 模块实例 + **metadata: 模块元数据 + """ + self._modules[module_name] = { + 'instance': module_instance, + **metadata + } + + def get_module(self, module_name: str) -> Optional[Any]: + """ + 获取模块实例 + + Args: + module_name: 模块名称 + + Returns: + 模块实例,如果不存在返回None + """ + module_info = self._modules.get(module_name) + return module_info['instance'] if module_info else None + + def get_actions_by_module(self, module_name: str) -> Dict[str, ActionConfig]: + """ + 获取指定模块的所有操作 + + Args: + module_name: 模块名称 + + Returns: + 操作字典 + """ + return { + name: config + for name, config in self._actions.items() + if config.module_name == module_name + } + + +# 全局模块注册表实例 +registry = ModuleRegistry() diff --git a/app/module/F6_Plugin_module.py b/app/module/F6_Plugin_module.py new file mode 100644 index 0000000..8d96be0 --- /dev/null +++ b/app/module/F6_Plugin_module.py @@ -0,0 +1,298 @@ +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 F6PluginModule: + + @staticmethod + def accept_file(data: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: # 接收文件 + """ + 接收文件。 + + 此方法用于处理前端上传的文件,下载文件并保存到指定目录。主要步骤包括: + 1. 处理前端传递的数据,获取文件的URL。 + 2. 解析URL以获取文件名。 + 3. 根据当前时间生成新的文件名,以避免文件名冲突。 + 4. 下载文件并保存到指定目录。 + 5. 返回文件保存路径和处理后的数据。 + + Args: + data (dict): 包含文件URL和其他必要信息的字典。 + + Returns: + tuple: 包含文件保存路径和处理后的数据的元组。如果文件保存成功,返回保存路径和数据;如果失败,返回 None 和数据。 + """ + data = api_instance.entry_data_get(data=data) + print(data) + try: + # 安全地访问附件信息 + data_dict = data.get('data', {}) + attachments = data_dict.get('附件', []) + + if not attachments or len(attachments) == 0: + print('上传url未读取到,或无上传文件: 附件列表为空') + save_path = '' + return save_path, data + + first_attachment = attachments[0] + url = first_attachment.get('url') + + if not url: + print('上传url未读取到,或无上传文件: URL为空') + save_path = '' + return save_path, data + + print(url) + except (KeyError, IndexError, TypeError) as e: + print(f'上传url未读取到,或无上传文件:{e}') + save_path = '' + return save_path, data + + parsed_url = urllib.parse.urlparse(url) + query_params = urllib.parse.parse_qs(parsed_url.query) + attname = query_params.get('attname', [''])[0] + filename = urllib.parse.unquote(attname) + + # 获取当前时间并格式化为指定格式的字符串 + timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + + # 分离文件名和扩展名 + name_part, ext_part = filename.rsplit('.', 1) if '.' in filename else (filename, '') + + # 构建新文件名 + new_filename = f"{name_part}{timestamp}.{ext_part}" if ext_part else f"{name_part}{timestamp}" + + save_path = os.path.join(Config.SAVE_DIRECTORY, new_filename) + print(save_path) + response = requests.get(url, stream=True) + + if response.status_code == 200: + with open(save_path, 'wb') as file: + for chunk in response.iter_content(chunk_size=1024): + if chunk: + file.write(chunk) + return save_path, data + else: + return None, data + + + def check_file(self, data: Dict[str, Any]) -> Dict[str, str]: # 校验上传文件 + """ + 校验上传文件。 + + 此方法负责接收前端上传的文件,并根据文件类型和操作指令进行相应的校验。主要步骤包括: + 1. 调用 `accept_file` 方法处理前端传递的数据,获取文件保存路径和处理后的数据。 + 2. 根据数据中的 `Action` 字段判断需要执行的操作类型。 + 3. 如果文件保存路径有效,继续执行以下步骤: + - 如果操作类型为 `create_brand`,则读取文件和模板文件,校验文件格式是否正确。 + - 如果文件格式正确,返回成功消息;否则返回错误消息。 + 4. 如果文件保存路径无效,返回相应的错误消息。 + 5. 如果读取文件过程中发生异常,捕获异常并返回错误消息。 + + Args: + data (dict): 前端请求发送过来的数据,包含文件信息和其他必要参数。 + + Returns: + dict: 包含文件校验结果的消息字典。如果校验成功,则返回文件路径和校验标志;如果失败,则返回错误消息。 + """ + save_path, data1 = self.accept_file(data) + + # 安全地获取 Action 字段 + data_dict = data1.get('data', {}) + action = data_dict.get('Action(隐藏)') + + if not action: + return {'msg': '缺少Action字段,无法校验文件'} + + if save_path: + try: + if action == 'create_brand': + df1 = pd.read_excel(save_path, sheet_name=0) + if "品牌" in df1.columns[0]: # 校验表头名字 + print('文件校验成功') + return {'msg': f'{save_path}', 'check': '是'} + else: + print("'msg':'文件上传格式错误'") + return {'msg': '文件上传格式错误'} + elif action == 'modify_customer_info': + df = pd.read_excel(save_path, sheet_name=0) + if "客户手机号" in df.columns[0]: # 校验表头名字 + print('文件校验成功') + return {'msg': f'{save_path}', 'check': '是'} + else: + print("'msg':'文件上传格式错误'") + return {'msg': '文件上传格式错误'} + elif action == 'delete_cars': + pass + else: + pass + + except Exception as e: + return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'} + + else: + return {'msg': '当前节点无附件上传', 'check': '是'} + + + @staticmethod + def create_brand(data: Dict[str, Any]) -> Dict[str, str]: # 创建品牌 + entry_data = api_instance.entry_data_get(data=data) + print('执行 品牌批量新建') + username = entry_data['data']['账号'] + password = entry_data['data']['密码'] + company_name = entry_data['data']['公司名称'] + save_path = entry_data['data']['文件保存地址'] + + login_response = F6Module.login_in(username, password, company_name) + if login_response is None: + return {'msg': '登录失败'} + + try: + df = pd.read_excel(save_path, sheet_name=0, dtype='string') + except Exception as e: + return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'} + + cookies = requests.utils.dict_from_cookiejar(login_response.cookies) + + try: + thread = threading.Thread(target=back_ground_tasks.create_brand_background, + args=(data, cookies, df, save_path)) + thread.start() + except Exception as e: + print(f'创建线程失败: {str(e)}') + + return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'} + + @staticmethod + def delete_history(data: Dict[str, Any]) -> Dict[str, str]: + entry_data = api_instance.entry_data_get(data=data) + username = entry_data['data']['账号'] + password = entry_data['data']['密码'] + company_name = entry_data['data']['公司名称'] + org_name = entry_data['data']['门店名称'] + + login_response = F6Module.login_in(username, password, company_name) + if login_response is None: + return {'msg': '未执行', 'msg_details': '登录失败'} + + cookies = requests.utils.dict_from_cookiejar(login_response.cookies) + + url = 'https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=1000&name=' + res = requests.get(url=url, cookies=cookies) + store_data = res.json() + org_lists = store_data['data']['list'] + + org_id = '' + org_name1 = '' + for org in org_lists: + org_name1 = org['orgName'] + if org_name == org['orgName']: + org_id = org['orgId'] + + if org_id: + thread = threading.Thread(target=back_ground_tasks.delete_history_background, + args=(data, cookies, org_id, org_name1)) + thread.start() + return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'} + else: + return {'msg': '未执行', 'msg_details': '门店信息错误'} + + @staticmethod + def delete_customer(data): + entry_data = api_instance.entry_data_get(data=data) + username = entry_data['data']['账号'] + password = entry_data['data']['密码'] + company_name = entry_data['data']['公司名称'] + + res = F6Module.login_in(username, password, company_name) + + if res is not None: + cookies = requests.utils.dict_from_cookiejar(res.cookies) + url = "https://yunxiu.f6car.cn/member/customer/listForPermission?pageSize=50000&pageNo=1" + res = requests.get(url, cookies=cookies) + json = res.json() + + if json: + thread = threading.Thread(target=back_ground_tasks.delete_customer_background, + args=(data, cookies, json['data']['data'],)) + thread.start() + return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'} + else: + return {'msg': '未执行', 'msg_details': '无客户信息'} + else: + return {'msg': '未执行', 'msg_details': '登录失败'} + + @staticmethod + def delete_cars(data): + entry_data = api_instance.entry_data_get(data=data) + username = entry_data['data']['账号'] + password = entry_data['data']['密码'] + company_name = entry_data['data']['公司名称'] + + res = F6Module.login_in(username, password, company_name) + + if res is not None: + cookies = requests.utils.dict_from_cookiejar(res.cookies) + url = "https://yunxiu.f6car.cn/member/car/carListForPermission" + header = { + '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' + } + + json_data = {"pageSize": 100, "pageNo": 1} + res = requests.post(url=url, cookies=cookies, json=json_data, headers=header) + res_data = res.json() + total_items = int(res_data["data"]['total']) + all_page = total_items // 100 + (total_items % 100 > 0) + + if res_data: + thread = threading.Thread(target=back_ground_tasks.delete_car_background, + args=(data, url, cookies, header, all_page)) + thread.start() + return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'} + else: + return {'msg': '未执行', 'msg_details': '无客户车辆信息'} + + else: + return {'msg': '未执行', 'msg_details': '登录失败'} + + def modify_customer_info(self, data: Dict[str, str]): + entry_data = api_instance.entry_data_get(data=data) + username = entry_data['data']['账号'] + password = entry_data['data']['密码'] + company_name = entry_data['data']['公司名称'] + save_path = entry_data['data']['文件保存地址'] + + login_response = F6Module.login_in(username, password, company_name) + if login_response is None: + return {'msg': '未执行', 'msg_details': '登录失败'} + + cookies = requests.utils.dict_from_cookiejar(login_response.cookies) + + try: + df = pd.read_excel(save_path, sheet_name=0, dtype='string') + except Exception as e: + return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'} + + if cookies: + thread = threading.Thread(target=back_ground_tasks.modify_customer_info_background, + args=(data, cookies, df, save_path)) + thread.start() + return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'} + else: + return {'msg': '未执行', 'msg_details': 'cookies获取失败'} + + diff --git a/app/module/__init__.py b/app/module/__init__.py new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/app/module/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/app/module/module.py b/app/module/module.py new file mode 100644 index 0000000..11b5f1e --- /dev/null +++ b/app/module/module.py @@ -0,0 +1,226 @@ +import requests +import hashlib +from urllib.parse import quote +from datetime import datetime +from app.api import API +from typing import Optional, Dict, AnyStr +from PIL import Image, ImageEnhance +import pytesseract +import logging +from datetime import datetime + +api_instance = API() +logger = logging.getLogger('app') + + +class F6Module: + @staticmethod + def get_captcha() -> AnyStr: + captcha_url = 'https://yunxiu.f6car.cn/kzf6/login/captcha-image' + response = requests.get(captcha_url) + with open('captcha.png', 'wb') as f: + f.write(response.content) + + image = Image.open('captcha.png') + enhancer = ImageEnhance.Contrast(image) + image = enhancer.enhance(2.0) + enhancer = ImageEnhance.Brightness(image) + image = enhancer.enhance(1.5) + image = image.convert('L') + image = image.point(lambda x: 0 if x < 128 else 255, '1') + image.save('preprocessed_captcha.png') + + captcha_text = pytesseract.image_to_string(image) + print(f"识别的验证码为: {captcha_text}") + + return captcha_text + + @staticmethod + def login_in(username: str, password: str, company_name: str = '默认门店',) -> Optional[requests.Response]: + url = "https://yunxiu.f6car.com/kzf6/login/confirm" + session = requests.Session() + header = { + 'Referer': url, + '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' + } + data = { + 'username': username, + 'password': hashlib.md5(password.encode('utf-8')).hexdigest(), + } + try: + res = session.post(url=url, headers=header, data=data) + res_json = res.json() + + if res_json.get('message') == '请输入图形验证码': + logger.warning("触发图形验证码") + captcha_text = F6Module.get_captcha() + data.update({'imageCode': captcha_text}) + res = session.post(url=url, headers=header, data=data) + res_json = res.json() + + if res_json.get("data") is None: + return res + else: + group_id = '' + for group in res_json.get('data', []): + if group.get("groupName") == company_name: + group_id = group.get("groupId") + break + + if not group_id: + logger.error(f"未找到公司名称: {company_name}") + return None + + token = quote(res_json['token']) # URL 编码 + url = (f'https://yunxiu.f6car.cn/kzf6/user/loginAfterChooseGroup?' + f'token={token}&groupId={group_id}&macAddress=') + res1 = session.get(url, cookies=res.cookies) + return res1 + except Exception as e: + print(f"Error during login: {e}") + return None + + def accept_login_message(self, data: Dict[str, str]) -> Dict[str, str]: + username = data['username'] + password = data['password'] + company_name = data['company_name'] + + res = self.login_in(username, password, company_name) + + if res is not None: + cookies = requests.utils.dict_from_cookiejar(res.cookies) + json = res.json() + url = 'https://yunxiu.f6car.cn/hive/company/getGroupName' + res1 = requests.get(url=url, cookies=cookies) + data1 = res1.json() + + if data1['code'] == 200: + if data1['data'] == company_name: + if json['status'] == 'success': + json['status'] = '登录成功' + elif json['status'] == 'Error': + json['status'] = '登录失败,请检查账号密码' + else: + json['status'] = '公司名称不正确或未选择公司名称,请重试' + else: + json['status'] = '请输入正确的账号密码并选择公司名称' + return json + else: + return {"status": "登录失败,请检查公司名称"} + + def get_company_information(self, data: Dict[str, str]) -> Dict[str, str]: + username = data['username'] + password = data['password'] + timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + print(username) + + url = "https://yunxiu.f6car.com/kzf6/login/confirm" + session = requests.Session() + header = { + 'Referer': url, + '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' + } + data = { + 'username': username, + 'password': hashlib.md5(password.encode('utf-8')).hexdigest(), + } + + try: + res = session.post(url=url, headers=header, data=data) + res_json = res.json() + + if res_json.get('message') == '请输入图形验证码': + pass + + jiandaoyun_data = {'api_key': '6694d3c4fcb69ca9a111a6c4', 'entry_id': '6736e2112ad50045f041a827'} + + if res_json.get("data") is None: + print('单店') + res = self.login_in(username, password) + if res is not None: + cookies = requests.utils.dict_from_cookiejar(res.cookies) + url = 'https://yunxiu.f6car.cn/hive/company/getGroupName' + res = requests.get(url=url, cookies=cookies) + data = res.json() + store_name = data['data'] + + jiandaoyun_data['data_list'] = [ + {"_widget_1731650067055": {"value": f'{username}{password}{timestamp}'}, + "_widget_1731650067056": {"value": f"{store_name}"}}] + api_instance.entry_data_batch_create(jiandaoyun_data) + res = {'msg': f'{username}{password}{timestamp}'} + else: + jiandaoyun_data_list = [] + for group in res_json.get('data', []): + append_data = {"_widget_1731650067055": {"value": f'{username}{password}{timestamp}'}, + "_widget_1731650067056": {"value": f"{group['groupName']}"}} + jiandaoyun_data_list.append(append_data) + + jiandaoyun_data['data_list'] = jiandaoyun_data_list + + res = api_instance.entry_data_batch_create(jiandaoyun_data) + + print(res) + + res = {'msg': f'{username}{password}{timestamp}'} + return res + + except Exception as e: + print(f"获取公司名称失败: {e}") + res = {'msg': '获取公司名称失败,请重新获取'} + return res + + def get_store_information(self, data: Dict[str, str]) -> Dict[str, dict[str, str]]: + username = data['username'] + password = data['password'] + company_name = data['company_name'] + timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S") + + login_response = self.login_in(username, password, company_name) + if login_response is None: + return {"msg": {'msg': '未执行', 'msg_details': '登录失败'}} + + cookies = requests.utils.dict_from_cookiejar(login_response.cookies) + + url = 'https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=100&name=' + res = requests.get(url=url, cookies=cookies) + data = res.json() + org_lists = data['data']['list'] + + url = 'https://yunxiu.f6car.cn/member/car/carListForPermission' + json = {"pageSize": 10, "pageNo": 1} + car_res = requests.post(url=url, json=json, cookies=cookies) + total_cars_data = car_res.json() + total_cars = total_cars_data['data']['total'] + + url = 'https://yunxiu.f6car.cn/member/customer/listForPermission?pageSize=10&pageNo=1' + customer_res = requests.get(url=url, cookies=cookies) + total_customer_data = customer_res.json() + total_customer = total_customer_data['data']['total'] + + jiandaoyun_data = {'api_key': '6694d3c4fcb69ca9a111a6c4', + 'entry_id': '673c38ccca57a5cf266eb18c'} + + jiandaoyun_data_list = [] + for org in org_lists: + append_data = {"_widget_1731999948708": {"value": f'{username}{password}{company_name}{timestamp}'}, + "_widget_1731999948709": {"value": f"{org['orgName']}"}} + jiandaoyun_data_list.append(append_data) + + jiandaoyun_data['data_list'] = jiandaoyun_data_list + + api_instance.entry_data_batch_create(jiandaoyun_data) + + res = {'msg': {"msg": f'{username}{password}{company_name}{timestamp}', + "total_cars": f"{total_cars}条客户车辆", + "total_customer": f"{total_customer}条客户"}} + + return res + + @staticmethod + def get_keep_heart(data: Dict[str, str]) -> Dict[str, str]: + return data + + diff --git a/app/module/other_module.py b/app/module/other_module.py new file mode 100644 index 0000000..03f0e54 --- /dev/null +++ b/app/module/other_module.py @@ -0,0 +1,22 @@ +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): + pass + + diff --git a/app/tasks/README.md b/app/tasks/README.md new file mode 100644 index 0000000..b65f499 --- /dev/null +++ b/app/tasks/README.md @@ -0,0 +1,89 @@ +# 后台任务模块结构说明 + +## 模块结构 + +后台任务已按功能拆分为以下模块: + +``` +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 new file mode 100644 index 0000000..e942da5 --- /dev/null +++ b/app/tasks/__init__.py @@ -0,0 +1,34 @@ +""" +后台任务模块统一导出入口 +保持向后兼容,所有原有导入方式仍然有效 +""" +# 通用功能 +from app.tasks.common import update_jiandaoyun, approve_workflow + +# 品牌相关任务 +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 + +__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/tasks/brand_tasks.py b/app/tasks/brand_tasks.py new file mode 100644 index 0000000..adb6719 --- /dev/null +++ b/app/tasks/brand_tasks.py @@ -0,0 +1,55 @@ +""" +品牌相关后台任务模块 +包含品牌批量创建等功能 +""" +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 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 + """ + df = df.where(pd.notnull(df), None) + # 定义请求URL + url = 'https://yunxiu.f6car.cn/camaro/brand/getOrCreate' + # 遍历DataFrame中的每一行数据 + results = [] + for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="创建品牌"): + brand_name = row[df.columns[0]] + + if brand_name is None or pd.isna(brand_name) or str(brand_name).strip() == '': + results.append({f'{brand_name}': '无效品牌名', '状态': '跳过'}) + logger.warning(f"跳过无效品牌名: {brand_name}") + continue + + try: + response = requests.post(url, cookies=cookies, json={"brandName": brand_name}) + response.raise_for_status() # 抛出HTTP错误 + results.append({'品牌名': brand_name, '状态': '创建成功'}) + except requests.exceptions.RequestException as e: + results.append({'品牌名': brand_name, '状态': f'创建失败: {str(e)}'}) + print({'msg': '已执行', 'msg_details': f'{results}'}) + logger.info(f"品牌创建结果: {results}") + os.remove(save_path) + print(f'{save_path}已删除') + print(data) + # 调用api回写改掉 执行明细与执行状态 + msg = update_jiandaoyun(data, f'{results}') + + if msg.get('msg'): + approve_workflow(data) + print('表单已自动提交至下一步') + diff --git a/app/tasks/common.py b/app/tasks/common.py new file mode 100644 index 0000000..1977b26 --- /dev/null +++ b/app/tasks/common.py @@ -0,0 +1,94 @@ +""" +通用后台任务模块 +包含简道云表单更新和工作流审批等通用功能 +""" +import logging +import time +from typing import Dict, Any +from app.api import API + +api_instance = API() +logger = logging.getLogger('app') + + +def update_jiandaoyun(data: Dict[str, Any], results: str): + """ + 更新简道云表单 + :param data: 包含表单id、应用id、数据id的字典 + :param results: 执行结果信息 + :return: 更新结果字典 + """ + # 定义简道云数据配置 + jiandaoyun_data = { + 'api_key': data['api_key'], + 'entry_id': data['entry_id'], + 'data_id': data['data_id'], + "data": { + '_widget_1731379774828': {"value": "已执行"}, # f6系统批量操作测试 是否执行成功 + '_widget_1731381334870': {"value": results} # f6系统批量操作测试 执行明细 + } + } + + time.sleep(1) + print(jiandaoyun_data) + + try: + response = api_instance.entry_data_update(jiandaoyun_data) + logger.info(f"简道云表单更新成功: {response}") + return {'msg': True} + except Exception as e: + logger.error(f"简道云表单更新失败: {e}") + return {'msg': False} + + +def approve_workflow(data: Dict[str, Any]): + """ + 获取简道云当前流程节点并直接提交 + :param data: 包含表单id、应用id、数据id的字典 + :return: None + """ + # 获取简道云当前流程列表 + json = api_instance.workflow_instance_get(data) + + # 检查返回数据是否有效 + if not json: + logger.error("未获取到工作流实例信息") + return + + # 安全地获取任务列表 + tasks = json.get('tasks', []) + if not tasks: + logger.error("未找到待处理任务") + return + + # 将JSON字符串转换为Python字典 + username = '' + instance_id = '' + task_id = '' + + 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 + + if not username or not instance_id or not task_id: + logger.error("未找到有效的待处理任务信息") + return + + task_data = { + "username": username, + "instance_id": instance_id, + "task_id": task_id, + } + + try: + response = api_instance.workflow_task_approve(task_data) + logger.info(f"简道云工作流任务提交成功: {response}") + except Exception as e: + logger.error(f"简道云工作流任务提交失败: {e}") + diff --git a/app/tasks/customer_tasks.py b/app/tasks/customer_tasks.py new file mode 100644 index 0000000..613ee4b --- /dev/null +++ b/app/tasks/customer_tasks.py @@ -0,0 +1,261 @@ +""" +客户相关后台任务模块 +包含修改客户信息等功能 +""" +import logging +import requests +import pandas as pd +import time +import re +from typing import Dict, Any, Optional +from app.tasks.common import update_jiandaoyun, approve_workflow + +logger = logging.getLogger('app') + + +def modify_customer_info_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str): + """ + 修改客户信息后台任务。 + + 此函数用于后台任务,用于修改会员信息。 + + Args: + data (Dict[str, Any]): 前端请求发送过来的数据,包含文件信息和其他必要参数。 + cookies (Dict[str, str]): 登录用户的Cookies。 + + Returns: + None + """ + df = df.where(pd.notnull(df), None) + params = { + 'pageSize': 100, + 'pageNo': '1', + } + + res = requests.get( + 'https://yunxiu.f6car.cn/member/customer/listForPermission', + params=params, + cookies=cookies, + ) + + total = int(res.json().get("data").get("total")) + total_pages = (total // params["pageSize"]) + (1 if total % params["pageSize"] > 0 else 0) + print(f"总计{total_pages}页") + + all_customers = [] + max_retries = 10 + retry_count = 0 + for page in range(1, total_pages + 1): + print(f"正在请求第 {page} 页...") + params["pageNo"] = page + + while retry_count < max_retries: + response = requests.get( + 'https://yunxiu.f6car.cn/member/customer/listForPermission', + params=params, + cookies=cookies, + timeout=10 + ) + time.sleep(1) + if response.status_code == 200: + suppliers = response.json().get("data", {}).get("data", []) + all_customers.extend(suppliers) + break + else: + retry_count += 1 + print(f"请求第 {page} 页失败,正在重试(第 {retry_count} 次)...") + time.sleep(3) + + # 获取专属运营顾问列表 + json_data = { + 'includeStopedEmployee': False, + 'pageSize': 1000, + 'filterNullUser': False, + 'keyword': '', + 'idOwnOrgList': [], + } + + response = requests.post( + 'https://yunxiu.f6car.cn/hive/employee/searchStaffInGroup', + cookies=cookies, + json=json_data, + ) + + staff_list = response.json().get("data").get("list") + name_to_userid = { + emp['name']: emp['userId'] + for emp in staff_list + if emp['userId'] is not None + } + df['userId'] = df['专属运营顾问'].map(name_to_userid) + + def extract_province_city_district(address: Optional[str]) -> Dict[str, Optional[str]]: + """安全解析省市区信息,所有返回值都可能为None""" + if not address: + return {'省': None, '市': None, '区': None} + + try: + pattern = r'(?P<省>(?:[\u4e00-\u9fa5]+(?:省|自治区|特别行政区))?)' \ + r'(?P<市>(?:[\u4e00-\u9fa5]+(?:市|自治州|地区|盟))?)' \ + r'(?P<区>(?:[\u4e00-\u9fa5]+区|[\u4e00-\u9fa5]+县|[\u4e00-\u9fa5]+旗)?)' + match = re.match(pattern, address.strip()) + return {k: v if v else None for k, v in match.groupdict().items()} if match else {'省': None, '市': None, + '区': None} + except Exception: + return {'省': None, '市': None, '区': None} + + def safe_get(d: Optional[Dict], *keys, default=None): + """多层字典安全获取值,始终返回可能为None的值""" + if not isinstance(d, dict): + return default + + for key in keys: + d = d.get(key, {}) + if not isinstance(d, dict): + break + return d if d != {} else default + + def convert_to_request_data(original_data: Optional[Dict[str, Any]], df: pd.DataFrame) -> Dict[str, Any]: + """ + 完全安全的字典转换函数 + 特点: + 1. 每个字段的值都可能为None + 2. 不会因为任何字段为空而报错 + 3. 不使用任何默认值,完全保留原始数据的空值状态 + """ + customer_info = safe_get(original_data, 'data', 'customerInfo') if original_data else None + + address_parts = extract_province_city_district( + safe_get(customer_info, 'provinceCityAreaName') if customer_info else None + ) + + cell_phone = safe_get(customer_info, 'cellPhone') + + exclusive_info = None + df_row = None + if cell_phone and not df.empty: + matched_rows = df[df['客户手机号'] == cell_phone] + if not matched_rows.empty: + df_row = matched_rows.iloc[0] + exclusive_info = { + 'userId': df_row.get('userId'), + 'name': df_row.get('专属运营顾问') + } + + request_data = { + "pkId": safe_get(customer_info, 'idCustomer'), + "idCustomer": safe_get(customer_info, 'idCustomer'), + "name": df_row.get('客户姓名') if df_row is not None and pd.notna(df_row.get('客户姓名')) else safe_get( + customer_info, 'name'), + "sex": safe_get(customer_info, 'sex'), + "customerType": df_row.get('客户类型') if df_row is not None and pd.notna( + df_row.get('客户类型')) else safe_get( + customer_info, 'customerType'), + "customerSource": safe_get(customer_info, 'customerSource'), + "customerSourceName": df_row.get('客户来源') if df_row is not None and pd.notna( + df_row.get('客户来源')) else safe_get(customer_info, 'customerSourceName'), + "companyName": df_row.get('单位名称') if df_row is not None and pd.notna( + df_row.get('单位名称')) else safe_get( + customer_info, 'companyName'), + "cellPhone": cell_phone, + "wechart": safe_get(customer_info, 'wechart'), + "qq": safe_get(customer_info, 'qq'), + "contacts": safe_get(customer_info, 'contacts'), + "contactTelephone": safe_get(customer_info, 'contactTelephone'), + "province": safe_get(customer_info, 'province'), + "city": safe_get(customer_info, 'city'), + "area": safe_get(customer_info, 'area'), + "street": safe_get(customer_info, 'street'), + "address": safe_get(customer_info, 'address'), + "detailAddress": safe_get(customer_info, 'detailAddress'), + "pId": safe_get(customer_info, 'province'), + "cId": safe_get(customer_info, 'city'), + "aId": safe_get(customer_info, 'area'), + "provinceName": address_parts.get('省'), + "cityName": address_parts.get('市'), + "areaName": address_parts.get('区'), + "provinceCityAreaName": safe_get(customer_info, 'provinceCityAreaName'), + "birthday": safe_get(customer_info, 'birthday'), + "creationtime": safe_get(customer_info, 'creationtime'), + "modifiedtime": safe_get(customer_info, 'modifiedtime'), + "creator": safe_get(customer_info, 'creator'), + "creatorName": safe_get(customer_info, 'creatorName'), + "modifier": safe_get(customer_info, 'modifier'), + "idOwnOrg": safe_get(customer_info, 'idOwnOrg'), + "idOwnGroup": safe_get(customer_info, 'idOwnGroup'), + "insuranceCompany": safe_get(customer_info, 'insuranceCompany'), + "maritalStatus": safe_get(customer_info, 'maritalStatus'), + "monthlyIncome": safe_get(customer_info, 'monthlyIncome'), + "idNumber": safe_get(customer_info, 'idNumber'), + "personHobby": safe_get(customer_info, 'personHobby'), + "credentialsType": safe_get(customer_info, 'credentialsType'), + "points": safe_get(customer_info, 'points'), + "maxAccountAmount": safe_get(customer_info, 'maxAccountAmount'), + "pointsEnable": safe_get(customer_info, 'pointsEnable'), + "level": safe_get(customer_info, 'level'), + "memberCardNo": safe_get(customer_info, 'memberCardNo'), + "customerLevel": safe_get(customer_info, 'customerLevel'), + "exclusiveConsultantId": exclusive_info['userId'] if exclusive_info else safe_get(customer_info, + 'exclusiveConsultantId'), + "exclusiveConsultantName": exclusive_info['name'] if exclusive_info else safe_get(customer_info, + 'exclusiveConsultantName'), + "exclusiveOrgId": safe_get(customer_info, 'exclusiveOrgId'), + "exclusiveOrgName": safe_get(customer_info, 'exclusiveOrgName'), + "customerMemo": df_row.get('客户备注') if df_row is not None and pd.notna( + df_row.get('客户备注')) else safe_get( + customer_info, 'customerMemo'), + "isDel": safe_get(customer_info, 'isDel'), + "idFrom": safe_get(customer_info, 'idFrom'), + "mnemonic": safe_get(customer_info, 'mnemonic'), + "idOrgSource": safe_get(customer_info, 'idOrgSource'), + "firstArrivalIdSourceBill": safe_get(customer_info, 'firstArrivalIdSourceBill'), + "lastArrivalIdSourceBill": safe_get(customer_info, 'lastArrivalIdSourceBill'), + "customerInfoType": safe_get(customer_info, 'customerInfoType'), + "customerInfoCompleteDate": safe_get(customer_info, 'customerInfoCompleteDate'), + "customerInfoCompleteType": safe_get(customer_info, 'customerInfoCompleteType'), + "xczUserId": safe_get(customer_info, 'xczUserId'), + "xczUuid": safe_get(customer_info, 'xczUuid'), + "idWxbCustomer": safe_get(customer_info, 'idWxbCustomer'), + "promoteEmployeeId": safe_get(customer_info, 'promoteEmployeeId'), + "promoteEmployeeName": safe_get(customer_info, 'promoteEmployeeName'), + "promoteMemberId": safe_get(customer_info, 'promoteMemberId'), + "promoteMemberName": safe_get(customer_info, 'promoteMemberName'), + "driverExpiryDate": safe_get(customer_info, 'driverExpiryDate'), + "crmDeleteExclusiveFlag": safe_get(customer_info, 'crmDeleteExclusiveFlag'), + "totalObtainPoints": safe_get(customer_info, 'totalObtainPoints'), + "totalUsedPoints": safe_get(customer_info, 'totalUsedPoints'), + "orgName": safe_get(customer_info, 'orgName'), + "weChatFollower": safe_get(customer_info, 'weChatFollower'), + "pointsEnableConfig": safe_get(customer_info, 'pointsEnableConfig'), + "personalPointsEnableConfig": safe_get(customer_info, 'personalPointsEnableConfig'), + "pointsButtonStatus": safe_get(customer_info, 'pointsButtonStatus'), + "tmallInstallMember": safe_get(customer_info, 'tmallInstallMember'), + "corpId": safe_get(customer_info, 'corpId'), + "thirdCorpId": safe_get(customer_info, 'thirdCorpId'), + } + + return request_data + + for customer in all_customers: + phone = customer.get("cellPhone") + if phone in df["客户手机号"].tolist(): + print("开始修改") + cus_id = customer.get("idCustomer", {}) + cus_response = requests.get(f'https://yunxiu.f6car.cn/member/customer/{cus_id}', cookies=cookies) + original_data = cus_response.json() + final_json_data = convert_to_request_data(original_data, df) + response = requests.post( + 'https://yunxiu.f6car.cn/member/customer/modifyCustomer', + cookies=cookies, + json=final_json_data, + ) + print("修改完成") + + time.sleep(1) + + msg = update_jiandaoyun(data, f'修改完成') + + if msg.get('msg'): + approve_workflow(data) + print('表单已自动提交至下一步') + diff --git a/app/tasks/delete_tasks.py b/app/tasks/delete_tasks.py new file mode 100644 index 0000000..2b00130 --- /dev/null +++ b/app/tasks/delete_tasks.py @@ -0,0 +1,304 @@ +""" +删除相关后台任务模块 +包含删除历史维修记录、删除客户信息、删除客户车辆信息等功能 +""" +import logging +import traceback +import requests +import time +from typing import Dict, Any, List +from datetime import datetime +from tqdm import tqdm +from app.tasks.common import update_jiandaoyun, approve_workflow + +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 + """ + url = f'https://yunxiu.f6car.cn/maintain-dump/maintainHistory/?orgid={org_id}' # 删除url + res = requests.delete(url=url, cookies=cookies) + res_data = res.json() + + if res.status_code == 200 and res_data.get('code') == 200: + results = f'{org_name} 历史维修记录已删除' + print(results) + logger.info(f"删除 {org_name} 历史维修记录成功") + else: + results = f'删除 {org_name} 历史维修记录失败: {res_data.get("message")}' + print(results) + logger.error(f"删除 {org_name} 历史维修记录失败: {res_data.get('message')}") + + # 调用api回写改掉 执行明细与执行状态 + time.sleep(1) + msg = update_jiandaoyun(data, f'{results}') + + if msg.get('msg'): + approve_workflow(data) + print('表单已自动提交至下一步') + + +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 + """ + 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("未获取到门店信息") + 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) + + for item in tqdm(json_data, desc="删除客户"): + id_customer = item['idCustomer'] + phone = item['cellPhone'] + consume_last_time = item['consumeLastTime'] + if consume_last_time: + print(f'{id_customer}最近消费时间: {consume_last_time},跳过删除') + logger.warning(f"{id_customer}最近消费时间: {consume_last_time},跳过删除") + continue + + if id_customer in card_list_customers: + logger.info(f"{id_customer} 存在会员卡,跳过删除") + fail += 1 + continue + + try: + url = f"https://yunxiu.f6car.cn/member/customer/{id_customer}" # 客户信息删除url + res = requests.delete(url, cookies=cookies) # 客户信息删除 + res_data = res.json() + if res_data.get('success'): + success += 1 + logger.info(f"客户删除成功: ID={id_customer}, 手机号={phone}") + else: + fail += 1 + logger.error(f"客户删除失败: ID={id_customer}, 手机号={phone}, 错误信息: {res_data.get('message')}") + time.sleep(0.2) + except Exception as e: + fail += 1 + print("删除失败,", item, id_customer, phone, e) + logger.error(f"删除客户时发生错误: ID={id_customer}, 手机号={phone}, 错误信息: {e}") + if success + fail < len(json_data): + continue + + now = datetime.now() + if 20 <= now.hour <= 8: + time.sleep(1) + else: + time.sleep(3) + + logger.info(f"客户删除结果: 成功次数={success}, 失败次数={fail}") + + msg = update_jiandaoyun(data, f'成功次数{success},失败次数{fail}') + + if msg.get('msg'): + approve_workflow(data) + print('表单已自动提交至下一步') + + +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 + """ + print(cookies) + success = 0 + fail = 0 + try: + # 确保 all_page 是一个整数 + 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("未获取到门店信息") + 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_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}") + 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) + + itemlist = [] + # 使用 range() 创建一个可迭代的对象 + for page in range(1, all_page + 1): + json_data = { + "pageSize": 100, + "pageNo": page + } + + # 获取当前页的数据 + res = requests.post(url=url, cookies=cookies, json=json_data, headers=header) + res.raise_for_status() # 检查请求是否成功 + response_data = res.json() + + if 'data' not in response_data or 'data' not in response_data['data']: + print(f"警告: 页码 {page} 返回的数据格式不正确") + continue + + items = response_data['data']['data'] + for item in items: + itemlist.append(item) + + for item in tqdm(itemlist, desc="删除车辆信息"): + car_id = item.get('tmCarInfo', {}).get('pkId') + customer_id = item.get('tmCustomerInfo', {}).get('pkId') + consume_last_time = item.get('tmCustomerInfo', {}).get('consumeLastTime') + + if consume_last_time: + logger.info(f"{customer_id}最近消费时间: {consume_last_time},跳过删除") + fail += 1 + continue + + if car_id in card_list_cars: + logger.info(f"{customer_id} 存在会员卡,跳过删除") + fail += 1 + continue + + if not car_id or not customer_id: + logger.info(f"页码 {page} 中缺少必要的ID信息") + fail += 1 + continue + + try: + delete_url = ( + f"https://yunxiu.f6car.cn/member/car/deleteCar/{car_id}/{customer_id}" + ) + delete_res = requests.delete(delete_url, cookies=cookies) + delete_res.raise_for_status() # 检查删除请求是否成功 + delete_data = delete_res.json() + + if delete_data.get('data'): + success += 1 + else: + fail += 1 + logger.error( + f"删除失败: 页码 {page}, 车辆ID {car_id}, 客户ID {customer_id}," + f" 错误信息: {delete_data.get('message', '未知错误')}") + print( + f"删除失败: 页码 {page}, 车辆ID {car_id}, 客户ID {customer_id}," + f" 错误信息: {delete_data.get('message', '未知错误')}") + + time.sleep(0.2) # 避免过快请求 + except requests.exceptions.RequestException as e: + fail += 1 + print(f"删除失败: 页码 {page}, 车辆ID {car_id}, 客户ID {customer_id}, 错误信息: {e}") + logger.error(f"删除失败: 页码 {page}, 车辆ID {car_id}, 客户ID {customer_id}, 错误信息: {e}") + continue + + now = datetime.now() + if 20 <= now.hour <= 8: + time.sleep(1) + else: + time.sleep(3) + + print(f"完成: 成功删除 {success} 辆车, 失败 {fail} 辆车") + logger.info(f"完成: 成功删除 {success} 辆车, 失败 {fail} 辆车") + + except ValueError as e: + print(f"Error converting all_page to integer: {e}") + traceback.print_exc() # 打印堆栈跟踪信息 + except Exception as e: + print(f"An unexpected error occurred: {e}") + traceback.print_exc() # 打印堆栈跟踪信息 + + msg = update_jiandaoyun(data, f'成功次数{success},失败次数{fail}') + + if msg.get('msg'): + approve_workflow(data) + print('表单已自动提交至下一步') + diff --git a/app/utils/app_tools.py b/app/utils/app_tools.py new file mode 100644 index 0000000..4c71652 --- /dev/null +++ b/app/utils/app_tools.py @@ -0,0 +1,88 @@ +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): + 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) + + 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): + 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 + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..aa236b9 --- /dev/null +++ b/main.py @@ -0,0 +1,96 @@ +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 + + +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() + 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: 返回任务处理的结果 + """ + 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') + + # 处理 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': '未知的操作'}) + + # 将任务放入消息队列 + 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) + + +if __name__ == '__main__': + import uvicorn + uvicorn.run("fastapi_app.main:app", host="0.0.0.0", port=5003, reload=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6914390 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +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 diff --git a/utils/app_tools.py b/utils/app_tools.py new file mode 100644 index 0000000..33a448d --- /dev/null +++ b/utils/app_tools.py @@ -0,0 +1,97 @@ +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 + +