Compare commits

...

11 Commits

Author SHA1 Message Date
panda f0fcea03bb 1.新增gitignore文件
2.新启动脚本制作
2026-01-30 11:41:05 +08:00
panda 838453b88f V2.1客户信息修改、项目批量停用、项目批量修改、材料批量修改功能上线 2026-01-28 15:26:48 +08:00
panda 98944ecbdc 新增并注册项目信息修改 2026-01-27 14:07:41 +08:00
panda 283f7849f8 新增项目信息修改 2026-01-22 10:40:53 +08:00
panda 5cde7f852a 1.客户信息修改,将硬编码改为动态取值
2.新增项目批量停用、材料批量修改功能
2026-01-08 10:19:16 +08:00
panda 3938c820b5 1.客户信息修改,将硬编码改为动态取值
2.新增项目批量停用、材料批量修改功能
2026-01-08 10:18:51 +08:00
panda c4c4ccc7e9 1.客户信息修改,将硬编码改为动态取值
2.新增项目批量停用、材料批量修改功能
2026-01-08 10:18:18 +08:00
panda 11e4151395 添加后台任务失败通知机制 2025-12-16 10:54:50 +08:00
panda 70c375a34e 新增失败预警功能 2025-12-11 09:52:59 +08:00
panda 1e83d5b19a 客户信息删除代码更新 2025-12-04 09:46:44 +08:00
panda 49fc75214f 简道云V2.0 2025-11-14 11:04:01 +08:00
45 changed files with 4842 additions and 4016 deletions
+179
View File
@@ -0,0 +1,179 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### 默认 template
# IntelliJ 文件
.idea
*.iml
out
gen
# 数据文件
*.csv
*.xlsx
*.xls
# 环境文件
.vscode
.conda
+8
View File
@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+3 -1
View File
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
</content>
<orderEntry type="jdk" jdkName="fastapi_app" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
-225
View File
@@ -1,225 +0,0 @@
# FastAPI项目架构说明文档
## 项目架构概览
本项目采用模块化、可扩展的架构设计,主要包含以下核心组件:
```
fastapi_app/
├── app/
│ ├── core/ # 核心模块(可选)
│ │ ├── header_manager.py # 请求头管理器
│ │ ├── module_registry.py # 模块注册表
│ │ └── __init__.py # 核心模块导出
│ ├── module/ # 业务模块
│ │ ├── F6_Plugin_module.py
│ │ ├── module.py
│ │ └── other_module.py
│ ├── tasks/ # 后台任务模块
│ │ ├── common.py
│ │ ├── brand_tasks.py
│ │ ├── delete_tasks.py
│ │ └── customer_tasks.py
│ ├── utils/ # 工具模块
│ │ └── app_tools.py
│ └── api.py # API接口
├── main.py # FastAPI应用入口
└── requirements_fastapi.txt # 依赖清单
```
## 核心组件说明
### 1. 请求头管理器(HeaderManager
统一管理不同模块的请求头配置。
预定义配置:
- `default`: 默认请求头
- `f6_login`: F6系统登录请求头
- `jiandaoyun_api`: 简道云API请求头
使用示例:
```python
from app.core.header_manager import HeaderManager
# 获取默认请求头
headers = HeaderManager.get_headers()
# 获取F6登录请求头
headers = HeaderManager.get_headers('f6_login')
# 自定义请求头
headers = HeaderManager.get_headers(
'default',
referer='https://example.com',
custom_headers={'X-Custom-Header': 'value'}
)
# 注册新模块的请求头
from app.core.header_manager import HeaderConfig
custom_config = HeaderConfig(
referer='https://new-module.com',
user_agent='Custom Agent'
)
HeaderManager.register_module_headers('new_module', custom_config)
```
### 2. 模块注册表(ModuleRegistry
统一注册和管理所有功能模块和操作(可选使用,主要用于请求头管理)。
注册操作:
```python
from app.core import core_manager
core_manager.register_action(
action_name='my_action',
handler=my_handler_function,
module_name='my_module',
description='操作描述',
requires_auth=True,
header_module='default'
)
```
获取操作:
```python
action_config = core_manager.registry.get_action('my_action')
if action_config:
result = action_config.handler(data)
```
## 添加新功能模块
### 步骤1:创建模块文件
`app/module/` 目录下创建新模块文件,例如 `new_module.py`
```python
class NewModule:
def my_action(self, data):
"""即刻执行的操作"""
# 处理逻辑
return {'msg': '执行成功'}
def my_background_action(self, data):
"""后台执行的操作"""
# 这个函数会启动后台线程
from app import back_ground_tasks
import threading
thread = threading.Thread(
target=back_ground_tasks.my_background_task,
args=(data,)
)
thread.start()
return {'msg': '正在执行中'}
```
### 步骤2:创建后台任务(如果需要)
`app/tasks/` 目录下创建任务文件,例如 `new_tasks.py`
```python
from app.tasks.common import update_jiandaoyun, approve_workflow
def my_background_task(data):
"""后台任务函数"""
# 执行耗时操作
result = "执行结果"
# 更新简道云表单
msg = update_jiandaoyun(data, result)
# 提交工作流
if msg.get('msg'):
approve_workflow(data)
```
### 步骤3:在 main.py 中注册操作
`main.py``get_action_map()` 函数中添加:
```python
def get_action_map() -> dict:
# ... 现有代码 ...
new_module = NewModule()
return {
# ... 现有操作 ...
'my_action': new_module.my_action,
'my_background_action': new_module.my_background_action,
}
```
### 步骤4:注册新的请求头(如果需要)
`app/core/header_manager.py` 中添加:
```python
# 在 HeaderManager 类中添加
NEW_MODULE_HEADERS = HeaderConfig(
referer='https://new-module.com',
user_agent='Custom User Agent',
custom_headers={'X-API-Key': 'your-api-key'}
)
_module_headers: Dict[str, HeaderConfig] = {
# ... 现有配置 ...
'new_module': NEW_MODULE_HEADERS,
}
```
## 执行方式说明
### 调度机制
项目使用原有的任务队列调度方式:
1. **所有操作**都通过 `app_tools.enqueue_task()` 放入任务队列
2. **同步等待**结果通过 `response_queue.get()` 获取
3. **后台任务**在模块函数内部使用 `threading.Thread` 启动线程
### 即刻执行 vs 后台执行
- **即刻执行**:函数直接返回结果,通过任务队列同步执行
- **后台执行**:函数内部启动线程执行耗时操作,立即返回"正在执行中"的提示
所有操作都通过统一的任务队列处理,后台任务由模块函数内部管理。
## API接口
### POST /webhook
统一处理所有请求的主接口。
请求头:
- `Action`: 操作名称
- `Check`: (可选)F6_Plugin 操作时的检查标志
请求体:
```json
{
"api_key": "...",
"entry_id": "...",
"data_id": "...",
"Action": "..." // F6_Plugin 操作时需要
}
```
## 最佳实践
1. **模块职责单一**:每个模块只负责一类功能
2. **统一注册**:所有操作都在 `main.py``get_action_map()` 中统一注册
3. **请求头管理**:使用 HeaderManager 统一管理请求头,避免硬编码(可选)
4. **错误处理**:在模块函数中统一处理异常
5. **日志记录**:使用统一的日志记录器 `logging.getLogger('app')`
6. **向后兼容**:保持 `app.back_ground_tasks` 的向后兼容性
7. **任务队列**:所有操作都通过 `app_tools.enqueue_task()` 放入任务队列统一处理
## 后续扩展建议
1. **添加中间件**:用于请求验证、日志记录等
2. **添加配置管理**:使用配置文件管理不同环境的设置
3. **添加任务队列**:使用 Celery 或 RQ 管理后台任务
4. **添加API文档**:使用 FastAPI 的自动文档功能
5. **添加单元测试**:为每个模块添加测试用例
-506
View File
@@ -1,506 +0,0 @@
# Bug 报告
## 严重程度说明
- 🔴 **严重**: 可能导致程序崩溃或数据丢失
- 🟡 **中等**: 可能导致功能异常或错误处理不当
- 🟢 **轻微**: 代码质量问题,不影响功能但需要改进
---
## 1. api.py (根目录) - 错误日志键名错误
### 位置
- 第540行:`entry_data_batch_delete` 方法
- 第654行:`workflow_task_hand_over` 方法
- 第701行:`get_upload_token` 方法
- 第745行:`upload_file` 方法
### 问题描述
🟡 **中等** - 在错误日志中使用了错误的键名 `data['data_list']`,但这些方法中可能不存在该键。
### 代码示例
```python
# 第540行 - entry_data_batch_delete
error_task_logger.error(
f"任务 {data['data_list'][start_index:end_index]} 连续{max_retries}次请求失败,放弃此次请求。")
# 应该是 data['data_ids']
# 第654行 - workflow_task_hand_over
error_task_logger.error(
f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。")
# 该方法没有 data_list 键
# 第701行 - get_upload_token
error_task_logger.error(
f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。")
# 该方法没有 data_list 键
# 第745行 - upload_file
error_task_logger.error(
f"任务 {data['data_list']} 连续{max_retries}次请求失败,放弃此次请求。")
# 该方法没有 data_list 键
```
### 修复建议
使用正确的键名或使用 `data.get()` 方法安全访问。
---
## 2. main.py - Handler 调用问题
### 位置
第76行和第80行
### 问题描述
🟡 **中等** - 当 `action_map.get()` 返回默认的 lambda 函数时,handler 是一个函数对象,但代码中直接将其传递给 `enqueue_task`,这应该是正确的。但如果 handler 是 lambda,需要确保它能被正确调用。
### 代码示例
```python
# 第76行
handler = action_map.get(sub_action, lambda x: {'msg': '未执行'})
# 第80行
handler = action_map.get(action, lambda x: {'msg': '未知的操作'})
```
### 修复建议
确保所有 handler 都能正确处理数据参数。如果 handler 可能返回 None,需要添加检查。
---
## 3. main.py - 队列阻塞风险
### 位置
第86行
### 问题描述
🟡 **中等** - 使用 `response_queue.get()` 会无限期阻塞,如果任务处理线程出现问题,可能导致请求永远挂起。
### 代码示例
```python
result = await anyio.to_thread.run_sync(response_queue.get)
```
### 修复建议
添加超时机制或使用 `response_queue.get(timeout=...)`
---
## 4. app/api.py - 数组访问未检查
### 位置
第712-713行:`get_upload_token` 方法
### 问题描述
🔴 **严重** - 直接访问 `res_j['token_and_url_list'][0]` 可能导致 KeyError 或 IndexError。
### 代码示例
```python
res_j = res.json()
upload_url = res_j['token_and_url_list'][0]['url']
upload_token = res_j['token_and_url_list'][0]['token']
```
### 修复建议
添加检查:
```python
token_list = res_j.get('token_and_url_list', [])
if not token_list:
raise ValueError("未获取到上传凭证")
upload_url = token_list[0].get('url')
upload_token = token_list[0].get('token')
```
---
## 5. app/module/F6_Plugin_module.py - 数组访问未检查
### 位置
第40行:`accept_file` 方法
### 问题描述
🔴 **严重** - 直接访问 `data['data']['附件'][0]['url']` 可能导致 KeyError 或 IndexError。
### 代码示例
```python
url = data['data']['附件'][0]['url']
```
### 修复建议
添加检查:
```python
attachments = data.get('data', {}).get('附件', [])
if not attachments:
return None, data
url = attachments[0].get('url')
```
---
## 6. app/module/F6_Plugin_module.py - 键访问未检查
### 位置
第96行:`check_file` 方法
### 问题描述
🔴 **严重** - 直接访问 `data1['data']['Action(隐藏)']` 可能导致 KeyError。
### 代码示例
```python
action = data1['data']['Action(隐藏)']
```
### 修复建议
使用安全访问:
```python
action = data1.get('data', {}).get('Action(隐藏)')
if not action:
return {'msg': '缺少Action字段'}
```
---
## 7. app/module/module.py - 临时文件未清理
### 位置
第21-31行:`get_captcha` 方法
### 问题描述
🟢 **轻微** - 创建了临时文件 `captcha.png``preprocessed_captcha.png`,但没有清理。
### 代码示例
```python
with open('captcha.png', 'wb') as f:
f.write(response.content)
# ... 处理文件 ...
image.save('preprocessed_captcha.png')
```
### 修复建议
使用临时文件或处理完后删除:
```python
import tempfile
import os
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
tmp.write(response.content)
tmp_path = tmp.name
try:
# 处理文件
...
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
```
---
## 8. app/module/module.py - group_id 可能为空
### 位置
第65-73行:`login_in` 方法
### 问题描述
🟡 **中等** - 如果找不到匹配的 `company_name``group_id` 为空字符串,后续请求可能失败。
### 代码示例
```python
group_id = ''
for group in res_json.get('data', []):
if group["groupName"] == company_name:
group_id = group.get("groupId")
token = quote(res_json['token'])
url = (f'https://yunxiu.f6car.cn/kzf6/user/loginAfterChooseGroup?'
f'token={token}&groupId={group_id}&macAddress=')
```
### 修复建议
添加检查:
```python
if not group_id:
logger.error(f"未找到公司名称: {company_name}")
return None
```
---
## 9. app/module/module.py - 时间判断逻辑错误
### 位置
第119行:`delete_customer_background` 方法(在 delete_tasks.py 中)
第246行:`delete_car_background` 方法(在 delete_tasks.py 中)
### 问题描述
🟡 **中等** - 时间判断逻辑错误:`if 20 <= now.hour <= 8:` 永远不会为 True(20 不可能小于等于 8)。
### 代码示例
```python
now = datetime.now()
if 20 <= now.hour <= 8:
time.sleep(1)
else:
time.sleep(3)
```
### 修复建议
应该是:
```python
now = datetime.now()
if 8 <= now.hour <= 20: # 8点到20点之间
time.sleep(3.5)
else: # 其他时间
time.sleep(1.5)
```
---
## 10. app/tasks/common.py - 数组访问未检查
### 位置
第56行:`approve_workflow` 方法
### 问题描述
🔴 **严重** - 直接访问 `json['tasks']``json['tasks'][0]` 可能导致 KeyError 或 IndexError。
### 代码示例
```python
for task in json['tasks']:
if task['status'] == 0:
username = task['assignee']['username']
instance_id = task['instance_id']
task_id = task['task_id']
```
### 修复建议
添加检查:
```python
tasks = json.get('tasks', [])
if not tasks:
logger.error("未找到待处理任务")
return
for task in tasks:
if task.get('status') == 0:
assignee = task.get('assignee', {})
username = assignee.get('username')
instance_id = task.get('instance_id')
task_id = task.get('task_id')
if username and instance_id and task_id:
break
```
---
## 11. app/tasks/delete_tasks.py - 数组访问未检查
### 位置
第62行:`delete_customer_background` 方法
第154行:`delete_car_background` 方法
### 问题描述
🔴 **严重** - 直接访问 `org_res.json().get("data").get("list")[0]` 可能导致 IndexError。
### 代码示例
```python
operate_org_id = org_res.json().get("data").get("list")[0].get("orgId")
```
### 修复建议
添加检查:
```python
org_data = org_res.json().get("data", {}).get("list", [])
if not org_data:
logger.error("未获取到门店信息")
return
operate_org_id = org_data[0].get("orgId")
```
---
## 12. app/tasks/customer_tasks.py - 重试逻辑错误
### 位置
第52-67行:`modify_customer_info_background` 方法
### 问题描述
🟡 **中等** - `retry_count` 在循环外部初始化,导致每次页面请求都使用相同的重试计数,而不是每页独立重试。
### 代码示例
```python
retry_count = 0
for page in range(1, total_pages + 1):
while retry_count < max_retries:
# ...
if response.status_code == 200:
break
else:
retry_count += 1
```
### 修复建议
`retry_count` 移到循环内部:
```python
for page in range(1, total_pages + 1):
retry_count = 0
while retry_count < max_retries:
# ...
```
---
## 13. app/tasks/delete_tasks.py - 条件判断错误
### 位置
第115-116行:`delete_customer_background` 方法
### 问题描述
🟡 **中等** - `if success + fail < len(json_data):` 这个条件判断逻辑有问题,应该检查是否还有未处理的项。
### 代码示例
```python
if success + fail < len(json_data):
continue
```
### 修复建议
这个条件判断似乎是想检查是否处理完所有项,但逻辑不正确。应该移除或修正。
---
## 14. app/tasks/delete_tasks.py - 变量作用域问题
### 位置
第215行:`delete_car_background` 方法
### 问题描述
🟡 **中等** - 在循环外部使用了 `page` 变量,但 `page` 只在循环内部定义。
### 代码示例
```python
for page in range(1, all_page + 1):
# ...
for item in items:
# ...
if not car_id or not customer_id:
logger.info(f"页码 {page} 中缺少必要的ID信息") # page 在这里可用
# ...
# ...
logger.error(f"删除失败: 页码 {page}, ...") # page 在这里可用
```
实际上这个不是bug`page` 在循环内部是可用的。但如果在循环外部使用就会有问题。
---
## 15. api.py (根目录) - 文件资源泄漏
### 位置
第721-747行:`upload_file` 方法
### 问题描述
🔴 **严重** - 文件在循环外部打开,如果循环中发生异常或提前返回,文件可能不会被关闭,导致资源泄漏。
### 代码示例
```python
f = open(file_path, 'rb')
files = {"file": f}
retries = 0
while retries <= max_retries:
try:
res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10)
# ...
if res.status_code == 200:
return data_get # 提前返回,文件未关闭!
# ...
except requests.exceptions.RequestException as e:
# 如果异常发生,文件可能不会被关闭
# ...
f.close() # 只有在循环结束后才会执行
```
### 修复建议
使用 `with` 语句确保文件总是被关闭:
```python
retries = 0
result = None
while retries <= max_retries:
try:
with open(file_path, 'rb') as f:
files = {"file": f}
res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10)
res.raise_for_status()
data_get = res.json()
if res.status_code == 200:
return data_get
retries += 1
time.sleep(3)
except requests.exceptions.RequestException as e:
logger.warning(f"请求异常: {e}, 将重新请求")
retries += 1
time.sleep(3)
if retries > max_retries:
error_task_logger.error(f"上传文件失败,已重试{max_retries}")
return None
```
---
## 16. api.py (根目录) - 重试时文件指针问题
### 位置
第721-747行:`upload_file` 方法
### 问题描述
🟡 **中等** - 文件在循环外部打开,重试时文件指针已经移动到文件末尾,后续重试会读取空内容。
### 代码示例
```python
f = open(file_path, 'rb')
files = {"file": f}
retries = 0
while retries <= max_retries:
res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10)
# 第一次请求后,文件指针已经移动到末尾
# 重试时会读取空内容
```
### 修复建议
每次重试时重新打开文件,或使用 `with` 语句在每次循环中打开文件。
---
## 总结
### 按严重程度分类
**严重 (🔴)** - 需要立即修复:
1. app/api.py - 数组访问未检查(get_upload_token
2. app/module/F6_Plugin_module.py - 数组访问未检查(accept_file
3. app/module/F6_Plugin_module.py - 键访问未检查(check_file
4. app/tasks/common.py - 数组访问未检查(approve_workflow
5. app/tasks/delete_tasks.py - 数组访问未检查(两处)
6. api.py (根目录) - 文件资源泄漏(upload_file
**中等 (🟡)** - 建议尽快修复:
1. api.py - 错误日志键名错误(多处)
2. api.py - 重试时文件指针问题(upload_file
3. main.py - Handler 调用和队列阻塞问题
4. app/module/module.py - group_id 可能为空
5. app/module/module.py - 时间判断逻辑错误(两处)
6. app/tasks/customer_tasks.py - 重试逻辑错误
7. app/tasks/delete_tasks.py - 条件判断错误
**轻微 (🟢)** - 代码质量改进:
1. app/module/module.py - 临时文件未清理
### 建议的修复优先级
1. **立即修复**:所有严重级别的bug,特别是可能导致程序崩溃的数组访问问题
2. **尽快修复**:中等级别的bug,特别是逻辑错误和错误处理问题
3. **代码改进**:轻微级别的bug,在时间允许时进行改进
-710
View File
@@ -1,710 +0,0 @@
# FastAPI 学习文档
## 📚 目录
1. [FastAPI 简介](#fastapi-简介)
2. [Flask vs FastAPI 对比](#flask-vs-fastapi-对比)
3. [FastAPI 核心概念](#fastapi-核心概念)
4. [项目中的 FastAPI 使用](#项目中的-fastapi-使用)
5. [学习路径建议](#学习路径建议)
6. [常见问题解答](#常见问题解答)
---
## FastAPI 简介
### 什么是 FastAPI
FastAPI 是一个现代、快速(高性能)的 Web 框架,用于构建 API,基于 Python 3.6+ 的类型提示(type hints)。
### 主要特点
1. **高性能**:与 NodeJS 和 Go 相当,是最快的 Python 框架之一
2. **快速开发**:开发速度提升约 200% 到 300%
3. **自动文档**:自动生成交互式 API 文档(Swagger UI
4. **类型提示**:基于 Python 类型提示,提供更好的 IDE 支持
5. **异步支持**:原生支持 async/await
6. **数据验证**:自动进行请求和响应数据验证
---
## Flask vs FastAPI 对比
### 1. 基本语法对比
#### Flask 写法
```python
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
data = request.get_json()
return jsonify({'msg': 'success'})
```
#### FastAPI 写法
```python
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
class RequestData(BaseModel):
name: str
age: int
@app.post("/webhook")
async def webhook(data: RequestData):
return JSONResponse({'msg': 'success'})
```
### 2. 主要区别
| 特性 | Flask | FastAPI |
|------|-------|---------|
| **异步支持** | 需要额外库(Flask-AsyncIO | 原生支持 async/await |
| **类型验证** | 手动验证 | 自动验证(基于 Pydantic |
| **API 文档** | 需要手动编写 | 自动生成(Swagger/OpenAPI |
| **性能** | 中等 | 高性能(接近 NodeJS |
| **依赖注入** | 需要额外库 | 内置支持 |
| **数据验证** | 手动处理 | 自动验证和转换 |
### 3. 请求处理对比
#### Flask
```python
from flask import request
@app.route('/api', methods=['POST'])
def api():
# 手动获取 JSON 数据
data = request.get_json()
# 手动验证
if not data or 'name' not in data:
return jsonify({'error': '缺少 name 字段'}), 400
return jsonify({'result': data['name']})
```
#### FastAPI
```python
from pydantic import BaseModel
class Item(BaseModel):
name: str
age: int
@app.post("/api")
async def api(item: Item):
# 自动验证,如果 name 缺失会自动返回 422 错误
return {'result': item.name}
```
---
## FastAPI 核心概念
### 1. 应用实例(FastAPI App
```python
from fastapi import FastAPI
# 创建 FastAPI 应用实例
app = FastAPI(
title="简道云FastAPI服务", # API 文档标题
description="简道云 API 服务", # API 描述
version="1.0.0" # 版本号
)
```
**项目中的使用**`main.py`):
```python
app = FastAPI(title="简道云FastAPI服务")
```
### 2. 路由装饰器(Route Decorators
FastAPI 使用装饰器定义路由,类似于 Flask:
```python
@app.get("/") # GET 请求
@app.post("/") # POST 请求
@app.put("/") # PUT 请求
@app.delete("/") # DELETE 请求
```
**项目中的使用**`main.py`):
```python
@app.post("/webhook")
async def webhook(request: Request):
# 处理逻辑
pass
```
### 3. 请求对象(Request
FastAPI 的 `Request` 对象提供了访问请求信息的方法:
```python
from fastapi import Request
@app.post("/webhook")
async def webhook(request: Request):
# 获取 JSON 数据
data = await request.json()
# 获取请求头
headers = request.headers
# 获取查询参数
query_params = request.query_params
# 获取路径参数
path_params = request.path_params
```
**项目中的使用**`main.py`):
```python
@app.post("/webhook")
async def webhook(request: Request):
# 获取请求数据
data = await request.json()
header = request.headers
# 解码请求头
decoded_header = app_tools.decode_headers(header)
```
### 4. 响应对象(Response
FastAPI 提供了多种响应类型:
```python
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
@app.post("/api")
async def api():
# JSON 响应
return JSONResponse({'msg': 'success'})
# 或者直接返回字典(FastAPI 会自动转换为 JSON
return {'msg': 'success'}
```
**项目中的使用**`main.py`):
```python
return JSONResponse(result)
```
### 5. 异步函数(Async Functions
FastAPI 支持异步函数,使用 `async``await`
```python
@app.post("/webhook")
async def webhook(request: Request):
# 异步获取 JSON 数据
data = await request.json()
# 异步调用其他函数
result = await some_async_function(data)
return result
```
**项目中的使用**`main.py`):
```python
@app.post("/webhook")
async def webhook(request: Request):
data = await request.json() # 异步获取数据
# ...
result = await anyio.to_thread.run_sync(response_queue.get) # 异步执行同步函数
```
### 6. 生命周期事件(Lifecycle Events
FastAPI 提供了应用启动和关闭事件:
```python
@app.on_event("startup")
async def startup_event():
"""应用启动时执行"""
print("应用启动")
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时执行"""
print("应用关闭")
```
**项目中的使用**`main.py`):
```python
@app.on_event("startup")
def on_startup():
"""应用启动时初始化"""
app.state.app_tools = AppTools(Config)
app.state.logger = setup_global_logger(Config)
app.state.f6_module = F6Module()
app.state.f6_plugin_module = F6PluginModule()
app.state.other_module = OtherPluginModule()
```
### 7. 应用状态(Application State
FastAPI 允许在应用实例上存储状态:
```python
# 设置状态
app.state.my_data = "some value"
# 获取状态
my_data = app.state.my_data
```
**项目中的使用**`main.py`):
```python
# 在启动时设置
app.state.app_tools = AppTools(Config)
app.state.logger = setup_global_logger(Config)
# 在路由中使用
logger = app.state.logger
app_tools: AppTools = app.state.app_tools
```
### 8. 数据验证(Pydantic Models
FastAPI 使用 Pydantic 进行数据验证:
```python
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str = None # 可选字段
@app.post("/users")
async def create_user(user: User):
# user 已经自动验证和转换
return {"name": user.name, "age": user.age}
```
**项目中的潜在使用**(可以改进):
```python
# 可以定义请求模型
class WebhookRequest(BaseModel):
api_key: str
entry_id: str
data_id: str
Action: str = None # 可选字段
@app.post("/webhook")
async def webhook(request: Request, data: WebhookRequest):
# data 已经自动验证
pass
```
---
## 项目中的 FastAPI 使用
### 1. 项目结构分析
```
fastapi_app/
├── main.py # FastAPI 应用入口
├── api.py # API 工具类(简道云 API 封装)
├── app/
│ ├── api.py # API 模块(简道云 API 封装)
│ ├── config.py # 配置管理
│ ├── module/ # 业务模块
│ ├── tasks/ # 后台任务
│ └── utils/ # 工具函数
└── requirements.txt # 依赖列表
```
### 2. main.py 详解
让我们逐步分析 `main.py` 文件:
#### 2.1 导入和初始化
```python
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import json
import anyio
from app.utils.app_tools import AppTools, setup_global_logger
from app.module.F6_Plugin_module import F6PluginModule
from app.module.module import F6Module
from app.module.other_module import OtherPluginModule
from app.config import Config
# 创建 FastAPI 应用实例
app = FastAPI(title="简道云FastAPI服务")
```
**说明**
- `FastAPI`:主应用类
- `Request`:请求对象,用于访问请求信息
- `JSONResponse`JSON 响应类
#### 2.2 启动事件
```python
@app.on_event("startup")
def on_startup():
"""应用启动时初始化"""
app.state.app_tools = AppTools(Config)
app.state.logger = setup_global_logger(Config)
app.state.f6_module = F6Module()
app.state.f6_plugin_module = F6PluginModule()
app.state.other_module = OtherPluginModule()
```
**说明**
- `@app.on_event("startup")`:应用启动时执行
- `app.state`:存储应用级别的状态
- 初始化工具类、日志记录器和业务模块
#### 2.3 路由处理
```python
@app.post("/webhook")
async def webhook(request: Request):
"""
接受前端请求后将任务放入消息队列
Returns:
any: 返回任务处理的结果
"""
logger = app.state.logger
app_tools: AppTools = app.state.app_tools
# 获取请求数据
data = await request.json()
header = request.headers
# 解码请求头
decoded_header = app_tools.decode_headers(header)
# 获取操作映射表
action_map = get_action_map()
action = decoded_header.get('Action')
# 处理逻辑...
# 将任务放入消息队列
response_queue = app_tools.enqueue_task(handler, data)
# 等待任务处理结果
result = await anyio.to_thread.run_sync(response_queue.get)
logger.info(json.dumps(result, ensure_ascii=False, indent=4))
return JSONResponse(result)
```
**关键点**
1. `async def`:异步函数定义
2. `await request.json()`:异步获取 JSON 数据
3. `await anyio.to_thread.run_sync()`:在线程池中执行同步函数
4. `JSONResponse`:返回 JSON 响应
### 3. 任务队列机制
项目使用自定义的任务队列来处理请求:
```python
# 在 app_tools.py 中
class AppTools:
def __init__(self, config):
self.task_queue = Queue()
self._start_task_thread()
def enqueue_task(self, handler, data):
response_queue = Queue()
self.task_queue.put({
'handler': handler,
'data': data,
'response': response_queue
})
return response_queue
```
**工作流程**
1. 请求到达 `/webhook` 端点
2. 将任务放入队列
3. 后台线程处理任务
4. 等待结果返回
### 4. 操作映射机制
项目使用操作映射表来路由不同的操作:
```python
def get_action_map() -> dict:
"""获取操作映射表"""
f6_module = app.state.f6_module
f6_plugin_module = app.state.f6_plugin_module
other_module = app.state.other_module
return {
'login_in': f6_module.accept_login_message,
'get_company_information': f6_module.get_company_information,
'create_brand': f6_plugin_module.create_brand,
# ... 更多操作
}
```
**说明**
- 通过请求头中的 `Action` 字段确定要执行的操作
- 使用字典映射操作名到处理函数
---
## 学习路径建议
### 阶段 1:基础理解(1-2 天)
1. **理解 FastAPI 基本概念**
- 阅读官方文档:https://fastapi.tiangolo.com/
- 理解路由、请求、响应的基本用法
2. **运行项目**
```bash
# 安装依赖
pip install -r requirements.txt
# 运行项目
python main.py
# 或
uvicorn main:app --reload
```
3. **访问 API 文档**
- 启动后访问:http://localhost:5003/docs
- 查看自动生成的 Swagger UI 文档
### 阶段 2:代码分析(2-3 天)
1. **分析 main.py**
- 理解应用初始化流程
- 理解路由处理逻辑
- 理解任务队列机制
2. **分析业务模块**
- 查看 `app/module/` 目录下的模块
- 理解操作映射机制
- 理解模块间的交互
3. **分析工具类**
- 查看 `app/utils/app_tools.py`
- 理解任务队列实现
- 理解日志记录机制
### 阶段 3:实践练习(3-5 天)
1. **添加新路由**
```python
@app.get("/health")
async def health_check():
return {"status": "ok"}
```
2. **添加数据验证**
```python
from pydantic import BaseModel
class WebhookData(BaseModel):
api_key: str
entry_id: str
data_id: str
@app.post("/webhook")
async def webhook(data: WebhookData):
return {"received": data.dict()}
```
3. **添加错误处理**
```python
from fastapi import HTTPException
@app.post("/webhook")
async def webhook(request: Request):
try:
data = await request.json()
# 处理逻辑
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
```
### 阶段 4:深入学习(1-2 周)
1. **学习异步编程**
- 理解 `async/await`
- 理解 `asyncio`
- 理解并发处理
2. **学习 Pydantic**
- 数据验证
- 数据序列化
- 自定义验证器
3. **学习依赖注入**
- FastAPI 的依赖注入系统
- 数据库连接管理
- 认证和授权
---
## 常见问题解答
### Q1: FastAPI 和 Flask 的主要区别是什么?
**A:** 主要区别:
1. **性能**FastAPI 性能更高,支持异步
2. **类型验证**FastAPI 自动验证,Flask 需要手动
3. **API 文档**FastAPI 自动生成,Flask 需要手动编写
4. **异步支持**FastAPI 原生支持,Flask 需要额外库
### Q2: 为什么使用 `async def` 而不是 `def`
**A:**
- `async def` 允许使用 `await` 关键字
- 可以异步处理 I/O 操作(如网络请求、数据库查询)
- 提高并发性能
**示例**
```python
# 同步(阻塞)
def sync_function():
data = requests.get("https://api.example.com") # 阻塞
return data
# 异步(非阻塞)
async def async_function():
async with httpx.AsyncClient() as client:
data = await client.get("https://api.example.com") # 非阻塞
return data
```
### Q3: `app.state` 是什么?为什么要用它?
**A:**
- `app.state` 是 FastAPI 提供的应用级状态存储
- 用于存储需要在多个路由之间共享的数据
- 类似于 Flask 的 `g` 对象,但作用域是整个应用
**项目中的使用**
```python
# 启动时设置
app.state.logger = setup_global_logger(Config)
# 在路由中使用
logger = app.state.logger
```
### Q4: 如何处理同步函数?
**A:**
使用 `anyio.to_thread.run_sync()` 在线程池中执行同步函数:
```python
import anyio
# 同步函数
def sync_function(data):
# 耗时操作
return result
# 在异步函数中调用
@app.post("/api")
async def api(request: Request):
result = await anyio.to_thread.run_sync(sync_function, data)
return result
```
### Q5: 如何添加中间件?
**A:**
使用 `@app.middleware()` 装饰器:
```python
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
```
### Q6: 如何添加 CORS 支持?
**A:**
使用 `CORSMiddleware`
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有源
allow_credentials=True,
allow_methods=["*"], # 允许所有方法
allow_headers=["*"], # 允许所有头
)
```
### Q7: 如何添加认证?
**A:**
使用依赖注入:
```python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer
security = HTTPBearer()
async def verify_token(token: str = Depends(security)):
if token != "valid_token":
raise HTTPException(status_code=401, detail="Invalid token")
return token
@app.post("/api")
async def api(token: str = Depends(verify_token)):
return {"message": "Authenticated"}
```
---
## 推荐资源
1. **官方文档**https://fastapi.tiangolo.com/
2. **中文文档**https://fastapi.tiangolo.com/zh/
3. **Pydantic 文档**https://docs.pydantic.dev/
4. **Uvicorn 文档**https://www.uvicorn.org/
---
## 总结
FastAPI 是一个现代、高性能的 Web 框架,特别适合构建 API。相比 Flask,它提供了:
1. **更好的性能**:异步支持,高性能
2. **自动验证**:基于类型提示的自动数据验证
3. **自动文档**:自动生成 API 文档
4. **更好的开发体验**:类型提示、IDE 支持
通过这个项目,你可以学习到:
- FastAPI 的基本用法
- 异步编程
- 任务队列机制
- 模块化架构设计
建议按照学习路径逐步深入,多实践、多思考,逐步掌握 FastAPI 的精髓。
-496
View File
@@ -1,496 +0,0 @@
# FastAPI 快速参考指南
## 🚀 快速开始
### 最小示例
```python
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
```
### 运行应用
```bash
# 方式 1:使用 uvicorn
uvicorn main:app --reload
# 方式 2:在代码中运行
if __name__ == '__main__':
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
```
---
## 📝 路由定义
### HTTP 方法
```python
@app.get("/items") # GET 请求
@app.post("/items") # POST 请求
@app.put("/items/{id}") # PUT 请求
@app.delete("/items/{id}") # DELETE 请求
@app.patch("/items/{id}") # PATCH 请求
```
### 路径参数
```python
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
# 多个路径参数
@app.get("/users/{user_id}/items/{item_id}")
async def read_user_item(user_id: int, item_id: int):
return {"user_id": user_id, "item_id": item_id}
```
### 查询参数
```python
@app.get("/items")
async def read_items(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}
# 可选参数
@app.get("/items")
async def read_items(q: str = None):
return {"q": q}
```
### 请求体
```python
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
description: str = None # 可选字段
@app.post("/items")
async def create_item(item: Item):
return item
```
---
## 🔧 请求和响应
### 获取请求数据
```python
from fastapi import Request
@app.post("/webhook")
async def webhook(request: Request):
# JSON 数据
data = await request.json()
# 请求头
headers = request.headers
# 查询参数
query_params = request.query_params
# 表单数据
form_data = await request.form()
# 文件上传
files = await request.form()
```
### 响应类型
```python
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse
@app.get("/json")
async def json_response():
return JSONResponse({"message": "Hello"})
@app.get("/html")
async def html_response():
return HTMLResponse("<h1>Hello</h1>")
@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` 深入学习。
-352
View File
@@ -1,352 +0,0 @@
# FastAPI 学习资源集合
欢迎!这个目录包含了学习 FastAPI 的完整资源,特别适合从 Flask 迁移到 FastAPI 的开发者。
## 📚 文档列表
### 1. [FastAPI 学习文档](./FASTAPI_LEARNING.md) 📖
**推荐首先阅读**
全面的 FastAPI 学习指南,包括:
- FastAPI 简介和特点
- Flask vs FastAPI 详细对比
- FastAPI 核心概念详解
- 项目中的实际应用分析
- 学习路径建议
- 常见问题解答
**适合**:想要系统学习 FastAPI 的初学者
### 2. [FastAPI 快速参考指南](./FASTAPI_QUICK_REFERENCE.md) ⚡
**日常开发速查手册**
快速查找常用功能的参考指南:
- 路由定义
- 请求和响应处理
- 数据验证
- 依赖注入
- 错误处理
- 异步编程
- 项目中的实际应用模式
**适合**:日常开发时快速查找语法和用法
### 3. [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md) 🔄
**迁移项目必读**
详细的迁移指南,包括:
- 迁移概览和步骤
- 核心概念对比表
- 代码迁移示例
- 当前项目的迁移分析
- 常见迁移问题解答
- 迁移检查清单
**适合**:正在从 Flask 迁移到 FastAPI 的开发者
---
## 🎯 学习路径
### 阶段 1:基础入门(1-2 天)
1. **阅读 [FastAPI 学习文档](./FASTAPI_LEARNING.md)**
- 理解 FastAPI 的基本概念
- 了解 Flask vs FastAPI 的区别
- 理解项目中的 FastAPI 使用
2. **运行项目**
```bash
# 安装依赖
pip install -r requirements.txt
# 运行项目
python main.py
# 或
uvicorn main:app --reload
```
3. **访问 API 文档**
- 启动后访问:http://localhost:5003/docs
- 查看自动生成的 Swagger UI 文档
- 尝试调用 API 端点
### 阶段 2:深入理解(2-3 天)
1. **分析项目代码**
- 阅读 `main.py`,理解应用初始化
- 阅读 `app/module/` 下的业务模块
- 理解任务队列机制
2. **参考 [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md)**
- 理解迁移过程
- 对比 Flask 和 FastAPI 的写法
- 理解项目中的迁移实现
3. **实践练习**
- 添加新的 API 端点
- 添加数据验证
- 添加错误处理
### 阶段 3:熟练应用(3-5 天)
1. **使用 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md)**
- 在日常开发中参考
- 尝试不同的 FastAPI 特性
- 优化现有代码
2. **深入学习**
- 学习异步编程
- 学习 Pydantic 高级特性
- 学习依赖注入系统
3. **项目改进**
- 重构代码使用 FastAPI 最佳实践
- 添加更多类型提示
- 优化性能
---
## 🚀 快速开始
### 1. 安装依赖
```bash
pip install fastapi uvicorn
```
### 2. 最小示例
```python
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}
```
### 3. 运行应用
```bash
uvicorn main:app --reload
```
### 4. 访问文档
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
---
## 📖 项目结构
```
fastapi_app/
├── main.py # FastAPI 应用入口
├── api.py # API 工具类
├── app/
│ ├── api.py # 简道云 API 封装
│ ├── config.py # 配置管理
│ ├── module/ # 业务模块
│ │ ├── F6_Plugin_module.py
│ │ ├── module.py
│ │ └── other_module.py
│ ├── tasks/ # 后台任务
│ └── utils/ # 工具函数
│ └── app_tools.py
├── requirements.txt # 依赖列表
├── FASTAPI_LEARNING.md # 📖 学习文档
├── FASTAPI_QUICK_REFERENCE.md # ⚡ 快速参考
└── FLASK_TO_FASTAPI_MIGRATION.md # 🔄 迁移指南
```
---
## 🔑 关键概念
### 1. 应用实例
```python
from fastapi import FastAPI
app = FastAPI(title="简道云FastAPI服务")
```
### 2. 路由定义
```python
@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
return JSONResponse(result)
```
### 3. 应用状态
```python
# 启动时设置
app.state.logger = setup_global_logger(Config)
# 路由中使用
logger = app.state.logger
```
### 4. 生命周期事件
```python
@app.on_event("startup")
def on_startup():
# 初始化代码
pass
```
---
## 💡 项目中的实际应用
### 1. 应用初始化
```python
# main.py
app = FastAPI(title="简道云FastAPI服务")
@app.on_event("startup")
def on_startup():
app.state.app_tools = AppTools(Config)
app.state.logger = setup_global_logger(Config)
app.state.f6_module = F6Module()
```
### 2. 路由处理
```python
@app.post("/webhook")
async def webhook(request: Request):
logger = app.state.logger
app_tools = app.state.app_tools
data = await request.json()
header = request.headers
# 处理逻辑
result = await anyio.to_thread.run_sync(response_queue.get)
return JSONResponse(result)
```
### 3. 任务队列
```python
# 将任务放入队列
response_queue = app_tools.enqueue_task(handler, data)
# 在线程池中执行同步函数
result = await anyio.to_thread.run_sync(response_queue.get)
```
---
## 📚 推荐资源
### 官方文档
- **FastAPI 官方文档**: https://fastapi.tiangolo.com/
- **FastAPI 中文文档**: https://fastapi.tiangolo.com/zh/
- **Pydantic 文档**: https://docs.pydantic.dev/
- **Uvicorn 文档**: https://www.uvicorn.org/
### 学习资源
- **FastAPI 教程**: https://fastapi.tiangolo.com/tutorial/
- **Python 异步编程**: https://docs.python.org/3/library/asyncio.html
- **类型提示**: https://docs.python.org/3/library/typing.html
---
## ❓ 常见问题
### Q: 我应该先读哪个文档?
**A:** 建议按以下顺序:
1. 先读 [FastAPI 学习文档](./FASTAPI_LEARNING.md) 了解基础
2. 再读 [Flask 到 FastAPI 迁移指南](./FLASK_TO_FASTAPI_MIGRATION.md) 理解迁移
3. 日常开发时参考 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md)
### Q: 如何理解项目中的异步代码?
**A:**
- FastAPI 使用 `async/await` 处理异步操作
- `await request.json()` 异步获取 JSON 数据
- `await anyio.to_thread.run_sync()` 在线程池中执行同步函数
- 详细说明见 [FastAPI 学习文档](./FASTAPI_LEARNING.md#异步编程)
### Q: 项目中的任务队列是如何工作的?
**A:**
- 使用 Python 的 `Queue` 和 `threading` 实现
- 请求到达后,任务放入队列
- 后台线程处理任务
- 使用 `anyio.to_thread.run_sync()` 等待结果
- 详细说明见 [FastAPI 学习文档](./FASTAPI_LEARNING.md#项目中的-fastapi-使用)
### Q: 如何添加新的 API 端点?
**A:**
1. 在 `main.py` 中添加路由函数
2. 在 `get_action_map()` 中注册操作(如果需要)
3. 参考 [快速参考指南](./FASTAPI_QUICK_REFERENCE.md#路由定义)
---
## 🎓 学习检查清单
### 基础理解
- [ ] 理解 FastAPI 的基本概念
- [ ] 理解 Flask vs FastAPI 的区别
- [ ] 能够创建简单的路由
- [ ] 理解异步编程(async/await
### 项目理解
- [ ] 理解项目结构
- [ ] 理解应用初始化流程
- [ ] 理解路由处理逻辑
- [ ] 理解任务队列机制
### 实践能力
- [ ] 能够添加新的路由
- [ ] 能够使用 Pydantic 进行数据验证
- [ ] 能够处理错误
- [ ] 能够使用应用状态
### 深入学习
- [ ] 理解依赖注入
- [ ] 理解中间件
- [ ] 理解生命周期事件
- [ ] 能够优化代码性能
---
## 📝 更新日志
- **2024-01-XX**: 创建 FastAPI 学习文档集合
- 添加 FastAPI 学习文档
- 添加快速参考指南
- 添加 Flask 到 FastAPI 迁移指南
---
## 🤝 贡献
如果你发现文档中有错误或需要改进的地方,欢迎提出建议!
---
**祝你学习愉快!** 🎉
如有问题,请参考相应的文档或查看 FastAPI 官方文档。
-620
View File
@@ -1,620 +0,0 @@
# Flask 到 FastAPI 迁移指南
## 📋 目录
1. [迁移概览](#迁移概览)
2. [核心概念对比](#核心概念对比)
3. [代码迁移示例](#代码迁移示例)
4. [项目迁移分析](#项目迁移分析)
5. [常见迁移问题](#常见迁移问题)
---
## 迁移概览
### 为什么迁移到 FastAPI
1. **性能提升**FastAPI 性能接近 NodeJS 和 Go
2. **自动文档**:自动生成 API 文档
3. **类型安全**:基于 Python 类型提示
4. **异步支持**:原生支持 async/await
5. **现代特性**:符合现代 Python 开发标准
### 迁移步骤
1.**安装 FastAPI**`pip install fastapi uvicorn`
2.**替换应用实例**`Flask()``FastAPI()`
3.**更新路由装饰器**:基本语法相同
4.**处理异步**:添加 `async/await`
5.**数据验证**:使用 Pydantic 模型
6.**更新响应**:使用 FastAPI 响应类
---
## 核心概念对比
### 1. 应用实例
#### Flask
```python
from flask import Flask
app = Flask(__name__)
```
#### FastAPI
```python
from fastapi import FastAPI
app = FastAPI()
```
### 2. 路由定义
#### Flask
```python
@app.route('/items', methods=['GET'])
def get_items():
return jsonify({'items': []})
```
#### FastAPI
```python
@app.get('/items')
async def get_items():
return {'items': []}
```
**区别**
- FastAPI 使用 `@app.get()` 而不是 `@app.route(methods=['GET'])`
- FastAPI 函数通常是 `async`
- FastAPI 直接返回字典,自动转换为 JSON
### 3. 获取请求数据
#### Flask
```python
from flask import request
@app.route('/items', methods=['POST'])
def create_item():
data = request.get_json()
name = data.get('name')
return jsonify({'name': name})
```
#### FastAPI
```python
from fastapi import Request
from pydantic import BaseModel
class Item(BaseModel):
name: str
@app.post('/items')
async def create_item(item: Item):
return {'name': item.name}
```
**区别**
- FastAPI 使用 Pydantic 模型自动验证
- 不需要手动获取 JSON 数据
- 类型自动验证和转换
### 4. 路径参数
#### Flask
```python
@app.route('/items/<int:item_id>')
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}` 而不是 `<int: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/<int:item_id>')
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/<int:user_id>', 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` 一起学习效果更好。
+394
View File
@@ -0,0 +1,394 @@
# 简道云 FastAPI 服务
简道云插件后端服务,提供数据同步和处理功能。
## 📋 项目简介
本项目是一个基于 FastAPI 框架的简道云插件后端服务,主要用于处理简道云插件发送的请求,执行各种业务操作。系统与 F6 汽车维修管理系统集成,提供以下核心功能:
- **F6系统集成**: 登录认证、公司信息获取、门店信息管理
- **文件处理**: 文件上传、校验、Excel批量处理
- **数据管理**: 品牌批量创建、客户信息管理、车辆信息管理
- **数据清理**: 历史记录删除、客户删除、车辆删除
- **BI任务**: 数据处理和报表生成
- **工作流自动化**: 自动提交简道云工作流
## 🏗️ 项目结构
```
fastapi_app/
├── main.py # 应用入口,生命周期管理
├── requirements.txt # Python依赖清单
├── README.md # 项目文档(本文件)
├── app/ # 应用主目录
│ ├── __init__.py
│ ├── config.py # 配置管理(目录、API Token等)
│ ├── schemas.py # Pydantic数据模型定义
│ │
│ ├── api/ # API路由模块
│ │ ├── __init__.py # 简道云API封装类
│ │ ├── routes.py # FastAPI路由定义
│ │ └── dependencies.py # 依赖注入函数
│ │
│ ├── core/ # 核心功能模块
│ │ ├── __init__.py # 核心管理器
│ │ └── module_registry.py # 模块注册表
│ │
│ ├── module/ # 业务模块
│ │ ├── __init__.py
│ │ ├── module.py # F6Module - F6系统相关功能
│ │ ├── f6_plugin_module.py # F6PluginModule - F6插件功能
│ │ └── other_module.py # OtherPluginModule - 其他功能
│ │
│ ├── tasks/ # 后台任务模块
│ │ ├── __init__.py # 任务统一导出
│ │ ├── common.py # 通用任务函数
│ │ ├── brand_tasks.py # 品牌相关任务
│ │ ├── customer_tasks.py # 客户相关任务
│ │ ├── delete_tasks.py # 删除相关任务
│ │ └── bi_tasks.py # BI相关任务
│ │
│ └── utils/ # 工具模块
│ └── app_tools.py # 应用工具类(日志、任务队列等)
├── logs/ # 日志目录
├── 下载文件/ # 下载文件存储目录
└── 模板文件/ # 模板文件存储目录
```
## 🚀 快速开始
### 环境要求
- Python 3.8+
- pip
- Tesseract OCR(用于验证码识别,可选)
### 安装依赖
```bash
pip install -r requirements.txt
```
### 配置说明
编辑 `app/config.py` 文件,配置以下内容:
- `JIANDAOYUN_API_TOKEN`: 简道云API Token(生产环境建议使用环境变量)
### 运行应用
```bash
# 方式1:直接运行
python main.py
# 方式2:使用 uvicorn(推荐)
uvicorn main:app --host 0.0.0.0 --port 5003 --reload
```
### 访问API文档
启动应用后,访问以下地址查看API文档:
- **Swagger UI**: http://localhost:5003/docs
- **ReDoc**: http://localhost:5003/redoc
## 📡 API 接口
### 健康检查
```http
GET /health
```
**响应示例:**
```json
{
"status": "ok",
"version": "2.0.0"
}
```
### Webhook 接口
```http
POST /webhook
```
**请求头:**
- `Action`: 操作类型(必需)
- `Check`: 检查标志(F6_Plugin 操作时需要,值为"是"或"否"
**请求体:**
```json
{
"api_key": "简道云应用ID",
"entry_id": "简道云表单ID",
"data_id": "简道云数据ID",
"Action": "操作类型(F6_Plugin 操作时需要)",
"username": "用户名",
"password": "密码",
"company_name": "公司名称"
}
```
**响应示例:**
```json
{
"msg": "操作完成",
"msg_details": "详细信息",
"status": "success"
}
```
## 🔧 支持的操作
### F6Module 操作
| 操作名 | 描述 | 说明 |
|--------|------|------|
| `login_in` | F6系统登录 | 使用用户名、密码登录F6系统,支持验证码识别 |
| `get_company_information` | 获取公司信息 | 获取F6系统中的公司列表,保存到简道云 |
| `get_store_information` | 获取门店信息 | 获取指定公司的门店列表及统计数据 |
| `keep_alive` | 保持连接 | 心跳检测,保持连接活跃 |
### F6PluginModule 操作
| 操作名 | 描述 | 说明 |
|--------|------|------|
| `check_file` | 校验上传文件 | 校验Excel文件格式,支持品牌创建、客户修改等场景 |
| `create_brand` | 创建品牌 | 批量创建品牌,从Excel文件读取品牌名称 |
| `delete_history` | 删除历史记录 | 删除指定门店的历史维修记录 |
| `delete_customer` | 删除客户 | 批量删除客户信息(会检查会员卡和消费记录) |
| `delete_cars` | 删除车辆 | 批量删除客户车辆信息(会检查会员卡和消费记录) |
| `modify_customer_info` | 修改客户信息 | 批量修改客户信息,从Excel文件读取修改内容 |
| `bi_task` | BI任务 | 执行BI相关任务(数据处理、报表生成等) |
### OtherPluginModule 操作
| 操作名 | 描述 | 说明 |
|--------|------|------|
| `sms_signature_status` | 短信签名状态 | 查询短信签名状态(待实现) |
## 🛠️ 技术栈
- **FastAPI**: 现代、快速的 Web 框架,支持异步处理
- **Pydantic**: 数据验证和设置管理
- **Uvicorn**: ASGI 服务器
- **APScheduler**: 后台任务调度
- **Requests**: HTTP 请求库
- **Pandas**: Excel文件处理和数据分析
- **Pillow**: 图像处理
- **Pytesseract**: OCR 识别(用于验证码识别)
## 📦 依赖列表
```
anyio==4.11.0
apscheduler==3.11.1
fastapi==0.121.0
log_config==2.1.1
numpy==2.3.4
pandas==2.3.3
Pillow==12.0.0
pytesseract==0.3.13
Requests==2.32.5
tqdm==4.67.1
uvicorn==0.38.0
```
## 🏛️ 架构设计
### 核心特性
1. **模块化设计**: 业务逻辑按模块分离,易于维护和扩展
2. **依赖注入**: 使用 FastAPI 的依赖注入系统,减少耦合
3. **模块注册表**: 使用 `module_registry` 统一管理所有操作
4. **路由分离**: API 路由分离到独立文件,职责清晰
5. **数据验证**: 使用 Pydantic schemas 进行请求和响应验证
6. **异常处理**: 完善的异常处理机制,统一的错误响应格式
7. **日志记录**: 规范的日志记录,支持日志轮转(单文件5MB,保留5个备份)
### 生命周期管理
应用使用 FastAPI 的 `lifespan` 功能管理应用生命周期:
- **启动时**:
- 初始化工具类(AppTools
- 设置全局日志记录器
- 初始化业务模块(F6Module、F6PluginModule、OtherPluginModule
- 注册所有操作到模块注册表
- **运行时**: 处理请求,执行业务逻辑
- **关闭时**: 清理资源,关闭后台调度器
### 任务处理机制
系统支持两种任务处理方式:
1. **同步任务**:
- 通过任务队列同步执行,等待结果返回
- 适用于快速操作(如登录、信息获取)
- 超时时间:55秒(简道云默认60秒超时)
2. **后台任务**:
- 在模块函数内部启动线程执行耗时操作
- 立即返回"正在执行中"的提示
- 适用于批量操作(如品牌创建、数据删除)
- 执行完成后自动更新简道云表单并提交工作流
### 简道云API封装
`app/api/__init__.py` 中的 `API` 类封装了简道云的所有API接口:
- **表单数据操作**: 获取、创建、更新、删除(单条/批量)
- **表单字段获取**: 获取表单字段信息,支持字段ID到标签名的替换
- **工作流操作**: 获取实例、审批、转交
- **文件操作**: 获取上传凭证、上传文件
- **重试机制**: 所有API调用都支持失败重试(默认最多20次)
## 🔐 配置说明
配置文件位于 `app/config.py`,主要配置项:
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| `BASE_DIR` | 项目根目录 | 自动获取 |
| `SAVE_DIRECTORY` | 下载文件保存目录 | `下载文件/` |
| `MODE_DIRECTORY` | 模板文件保存目录 | `模板文件/` |
| `LOGS_DIRECTORY` | 日志文件目录 | `logs/` |
| `LOG_FILE` | 日志文件路径 | `logs/简道云.log` |
| `JIANDAOYUN_API_TOKEN` | 简道云API Token | 需配置 |
## 📝 开发指南
### 添加新操作
1. **在模块中实现方法**:
```python
# app/module/your_module.py
class YourModule:
def your_action(self, data: Dict[str, Any]) -> Dict[str, str]:
# 实现业务逻辑
return {'msg': '操作完成'}
```
2. **在 main.py 中注册**:
```python
# 在 lifespan 函数中
core_manager.register_action(
'your_action',
your_module.your_action,
'your_module',
description='操作描述'
)
```
### 添加新路由
在 `app/api/routes.py` 中添加新的路由:
```python
@router.post("/your-endpoint", tags=["业务"])
async def your_endpoint(
request: Request,
logger: logging.Logger = Depends(get_logger)
):
# 实现路由逻辑
return {"message": "success"}
```
### 添加新的数据模型
在 `app/schemas.py` 中添加新的 Pydantic 模型:
```python
class YourRequest(BaseModel):
field1: str = Field(..., description="字段1")
field2: Optional[int] = Field(None, description="字段2")
```
### 添加后台任务
1. **在 tasks 目录下创建任务文件**:
```python
# app/tasks/your_tasks.py
def your_task_background(data, ...):
# 实现后台任务逻辑
# 完成后调用 update_jiandaoyun 和 approve_workflow
```
2. **在模块中调用**:
```python
import threading
from app.tasks.your_tasks import your_task_background
thread = threading.Thread(target=your_task_background, args=(...))
thread.start()
return {'msg': '正在执行中'}
```
## 🧪 测试
### 健康检查测试
```bash
curl http://localhost:5003/health
```
### Webhook 测试
```bash
curl -X POST http://localhost:5003/webhook \
-H "Content-Type: application/json" \
-H "Action: login_in" \
-d '{
"username": "test",
"password": "test",
"company_name": "测试公司"
}'
```
## 📊 日志
日志文件位于 `logs/简道云.log`,支持日志轮转:
- 单个日志文件最大 5MB
- 保留 5 个备份文件
- 使用 UTF-8 编码
- 日志格式:`时间戳 级别:模块名:消息`
## ⚠️ 注意事项
1. **API Token**: 当前 API Token 硬编码在配置文件中,生产环境建议使用环境变量管理
2. **CORS**: 当前配置允许所有来源,生产环境建议限制允许的来源
3. **超时时间**: 任务执行超时时间默认为 55 秒,可根据需要调整
4. **文件存储**: 下载的文件存储在 `下载文件/` 目录,注意定期清理
5. **验证码识别**: 需要系统安装 Tesseract OCR 才能正常识别验证码
6. **删除操作**: 删除客户和车辆时会检查会员卡和消费记录,有记录的数据会被跳过
7. **批量操作**: 批量操作在后台线程中执行,不会阻塞主请求
## 🔄 版本历史
- **v2.0.0**:
- fastapi 完全重构
- 支持 F6 系统相关操作
- 支持BI任务处理
- **1.0.0**: 初始版本
- 实现基本的 Webhook 接口
- 支持 F6 系统相关操作
## 📄 许可证
本项目为内部项目,仅供内部使用。
## 📞 联系方式
如有问题,请联系数据组。
---
**最后更新**: 2025年
-186
View File
@@ -1,186 +0,0 @@
# API模块更新说明
## 更新内容
根据 `fastapi_app/api.py`(最新版)更新了 `fastapi_app/app/api.py`,主要更新包括:
### 1. 添加失败重试机制
所有API函数都已添加重试机制,参考 `entry_data_list` 的实现模式:
- **默认重试次数**:20次(部分函数为10次)
- **重试逻辑**
- 捕获 `requests.exceptions.RequestException` 异常
- 重试前等待时间:0.1秒(快速请求)或3-10秒(慢速请求)
- 超过最大重试次数后记录错误日志并返回None或抛出异常
### 2. 新增功能函数
添加了以下新函数(原版本中没有的):
- `entry_data_banch_update()` - 批量修改数据
- `entry_data_delete()` - 删除单条数据
- `entry_data_batch_delete()` - 批量删除数据
- `workflow_task_hand_over()` - 流程待办转交
- `get_upload_token()` - 获取文件上传凭证
- `upload_file()` - 上传文件
### 3. 功能增强
- **`entry_data_get()`**
- 添加 `replace` 参数(默认True,保持向后兼容)
- 添加 `max_retries` 参数
- 添加重试机制
- **`entry_data_list()`**
- 添加 `replace` 参数(默认True,保持向后兼容)
- 添加 `max_retries` 参数
- 改进重试逻辑,支持分页重试
- **`entry_widget_list()`**
- 添加 `max_retries` 参数
- 添加重试机制
- **`data_batch_create()`**
- 添加 `max_retries` 参数
- 添加重试机制
- 支持 `is_start_workflow``is_start_trigger``transaction_id` 参数
- **`entry_data_batch_create()`**
- 添加 `max_retries` 参数
- 添加重试机制
- 支持 `is_start_workflow``is_start_trigger` 参数
- 使用 `NpEncoder` 处理NumPy数据类型
- **`entry_data_update()`**
- 添加 `max_retries` 参数
- 添加重试机制
- **`workflow_instance_get()`**
- 添加 `max_retries` 参数
- 添加重试机制
- **`workflow_task_approve()`**
- 添加 `max_retries` 参数
- 添加重试机制
- `comment` 参数支持自定义(默认"自动转交")
### 4. 工具函数
- **`NpEncoder`**:JSON编码器,处理NumPy数据类型
- **`replace_decimals()`**:递归替换Decimal类型为float
### 5. 字段替换优化
`field_replacement()` 方法使用递归实现,更优雅地处理嵌套数据结构。
## 兼容性保证
### 向后兼容
1. **默认参数**
- `entry_data_get()``entry_data_list()``replace` 参数默认为 `True`,保持原有行为
- 所有新增的 `max_retries` 参数都有合理的默认值
2. **导入路径**
- 使用 `from app.config import Config`(而非 `from config import Config`
- 使用 `logging.getLogger('app')`(而非 `log_config`
3. **函数签名**
- 所有原有函数的调用方式保持不变
- 新增参数都是可选参数
### 日志系统
- 使用 `logging.getLogger('app')` 作为常规日志记录器
- 使用 `logging.getLogger('app.error')` 作为错误日志记录器
- 与 fastapi_app 项目的日志系统完全兼容
## 使用示例
### 基本使用(保持原有方式)
```python
from app.api import API
api = API()
# 获取单条数据(自动替换字段)
data = api.entry_data_get({
'api_key': 'xxx',
'entry_id': 'xxx',
'data_id': 'xxx'
})
# 获取多条数据(自动替换字段)
data_list = api.entry_data_list({
'api_key': 'xxx',
'entry_id': 'xxx'
})
```
### 使用新功能
```python
# 不替换字段
data = api.entry_data_get({
'api_key': 'xxx',
'entry_id': 'xxx',
'data_id': 'xxx'
}, replace=False)
# 自定义重试次数
data = api.entry_data_get({
'api_key': 'xxx',
'entry_id': 'xxx',
'data_id': 'xxx'
}, max_retries=10)
# 批量删除
result = api.entry_data_batch_delete({
'api_key': 'xxx',
'entry_id': 'xxx',
'data_ids': ['id1', 'id2', 'id3']
}, chunk_size=90, max_retries=20)
```
## 重试机制说明
### 重试策略
1. **快速请求**0.1秒延迟):
- `entry_data_list()` - 分页请求
- `entry_data_batch_create()` - 批量创建
- `entry_data_batch_delete()` - 批量删除
- `workflow_instance_get()` - 流程查询
2. **慢速请求**3-10秒延迟):
- `data_batch_create()` - 3秒延迟
- `entry_data_update()` - 10秒延迟
- `entry_data_banch_update()` - 10秒延迟
- `entry_data_delete()` - 10秒延迟
- `workflow_task_approve()` - 3秒延迟
- `workflow_task_hand_over()` - 3秒延迟
- `get_upload_token()` - 3秒延迟
- `upload_file()` - 3秒延迟
### 错误处理
- 所有重试失败的操作都会记录到错误日志
- 部分函数在重试失败后返回 `None`,部分会抛出异常
- 错误日志包含失败的任务标识信息
## 注意事项
1. **超时设置**:所有请求都设置了 `timeout=10`
2. **状态码检查**:使用 `res.raise_for_status()` 检查HTTP状态码
3. **文件上传**`upload_file()` 使用 `with` 语句确保文件正确关闭
4. **数据类型处理**:批量操作函数使用 `NpEncoder``replace_decimals()` 处理特殊数据类型
## 测试建议
1. 测试所有原有功能的兼容性
2. 测试新添加的函数
3. 测试重试机制(可以模拟网络错误)
4. 测试 `replace=False` 参数的使用
-2
View File
@@ -1,2 +0,0 @@
__all__ = []
+56 -9
View File
@@ -1,6 +1,18 @@
"""
API 模块 - 简道云API接口封装
支持失败重试机制兼容现有代码
简道云 API 接口封装模块
本模块封装了简道云的所有 API 接口包括
- 表单数据操作获取创建更新删除
- 表单字段获取
- 工作流操作获取实例审批转交
- 文件操作获取上传凭证上传文件
特性
- 支持失败重试机制默认最多重试20次
- 支持字段替换将字段ID替换为标签名
- 支持批量操作批量创建更新删除
- 支持数据类型转换NumPyDecimal等
- 完善的错误处理和日志记录
"""
import requests
import json
@@ -17,7 +29,15 @@ error_logger = logging.getLogger('app.error') # 错误日志记录器
class NpEncoder(json.JSONEncoder):
"""NumPy数据类型JSON编码器"""
"""
NumPy 数据类型 JSON 编码器
用于将 NumPy 数据类型转换为 JSON 可序列化的类型
支持
- np.integer -> int
- np.floating -> float
- np.ndarray -> list
"""
def default(self, obj):
if isinstance(obj, np.integer):
return int(obj)
@@ -30,7 +50,18 @@ class NpEncoder(json.JSONEncoder):
def replace_decimals(obj):
"""递归替换Decimal类型为float"""
"""
递归替换 Decimal 类型为 float
递归遍历数据结构将所有 Decimal 类型替换为 float 类型
用于 JSON 序列化
Args:
obj: 要处理的对象可以是 dictlist 或其他类型
Returns:
处理后的对象所有 Decimal 类型已替换为 float
"""
if isinstance(obj, dict):
return {k: replace_decimals(v) for k, v in obj.items()}
elif isinstance(obj, list):
@@ -41,13 +72,29 @@ def replace_decimals(obj):
class API:
def entry_data_get(self, data: dict, replace: bool = True, max_retries: int = 20) -> Dict:
"""
简道云 API 接口封装类
提供简道云所有 API 接口的封装包括表单操作工作流操作文件操作等
所有方法都支持失败重试机制提高可靠性
"""
def entry_data_get(self, data: dict, replace: bool = False, max_retries: int = 20) -> Dict:
"""
获取单条表单数据
:param replace: 是否替换字段默认为True保持向后兼容
:param max_retries: 最大重试次数
:param data: 简道云插件发送过来的data包含应用id表单id数据id等信息
:return: 表单数据
根据应用ID表单ID和数据ID获取单条表单数据
Args:
data: 包含应用ID(api_key)表单ID(entry_id)数据ID(data_id)的字典
replace: 是否替换字段将字段ID替换为标签名默认为False
max_retries: 最大重试次数默认为20次
Returns:
Dict: 表单数据字典
Raises:
Exception: 如果重试max_retries次后仍然失败
"""
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/get'
+148
View File
@@ -0,0 +1,148 @@
"""
FastAPI 依赖注入模块
本模块提供 FastAPI 的依赖注入函数,用于在路由中注入可复用的依赖项。
使用依赖注入可以减少代码重复,提高可测试性和可维护性。
提供的依赖项:
- get_logger: 获取日志记录器
- get_app_tools: 获取应用工具实例
- get_f6_module: 获取 F6Module 实例
- get_f6_plugin_module: 获取 F6PluginModule 实例
- get_other_module: 获取 OtherPluginModule 实例
- get_action_map: 获取操作映射表
"""
from fastapi import Request
from typing import Optional
import logging
def get_logger(request: Request) -> logging.Logger:
"""
获取日志记录器依赖项
从应用状态中获取日志记录器,如果未初始化则返回一个基本的 logger。
Args:
request: FastAPI 请求对象
Returns:
logging.Logger: 日志记录器实例
"""
logger = getattr(request.app.state, 'logger', None)
if logger is None:
# 如果 logger 未初始化,返回一个基本的 logger
logger = logging.getLogger('app')
return logger
def get_app_tools(request: Request):
"""
获取应用工具实例依赖项
从应用状态中获取 AppTools 实例,用于任务队列管理等操作。
Args:
request: FastAPI 请求对象
Returns:
AppTools: 应用工具实例
Raises:
RuntimeError: 如果 AppTools 未初始化
"""
from app.utils.app_tools import AppTools
app_tools = getattr(request.app.state, 'app_tools', None)
if app_tools is None:
raise RuntimeError("AppTools 未初始化")
return app_tools
def get_f6_module(request: Request):
"""
获取 F6Module 实例依赖项
从应用状态中获取 F6Module 实例,用于 F6 系统相关操作。
Args:
request: FastAPI 请求对象
Returns:
F6Module: F6Module 实例
Raises:
RuntimeError: 如果 F6Module 未初始化
"""
f6_module = getattr(request.app.state, 'f6_module', None)
if f6_module is None:
raise RuntimeError("F6Module 未初始化")
return f6_module
def get_f6_plugin_module(request: Request):
"""
获取 F6PluginModule 实例依赖项
从应用状态中获取 F6PluginModule 实例,用于 F6 插件相关操作。
Args:
request: FastAPI 请求对象
Returns:
F6PluginModule: F6PluginModule 实例
Raises:
RuntimeError: 如果 F6PluginModule 未初始化
"""
f6_plugin_module = getattr(request.app.state, 'f6_plugin_module', None)
if f6_plugin_module is None:
raise RuntimeError("F6PluginModule 未初始化")
return f6_plugin_module
def get_other_module(request: Request):
"""
获取 OtherPluginModule 实例依赖项
从应用状态中获取 OtherPluginModule 实例,用于其他插件相关操作。
Args:
request: FastAPI 请求对象
Returns:
OtherPluginModule: OtherPluginModule 实例
Raises:
RuntimeError: 如果 OtherPluginModule 未初始化
"""
other_module = getattr(request.app.state, 'other_module', None)
if other_module is None:
raise RuntimeError("OtherPluginModule 未初始化")
return other_module
def get_action_map(request: Request) -> dict:
"""
获取操作映射表依赖项
从模块注册表中获取所有注册的操作,并转换为字典格式(操作名 -> 处理函数)。
Args:
request: FastAPI 请求对象
Returns:
dict: 操作映射表,格式为 {操作名: 处理函数}
"""
from app.core.module_registry import registry
# 从 registry 获取所有注册的操作
actions = registry.get_all_actions()
# 转换为字典格式(handler 函数)
action_map = {
action_name: config.handler
for action_name, config in actions.items()
}
return action_map
+212
View File
@@ -0,0 +1,212 @@
"""
API 路由定义模块
本模块定义所有 API 路由端点,包括:
- /health: 健康检查端点
- /webhook: Webhook 端点,处理简道云插件的请求
所有路由都使用 FastAPI 的依赖注入系统,通过 dependencies.py 中的函数注入依赖项。
"""
from fastapi import APIRouter, Request, HTTPException, status, Depends
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from pydantic import ValidationError
from typing import Dict, Any
import json
import anyio
import asyncio
import logging
from app.schemas import WebhookRequest, WebhookResponse, HealthResponse
from app.api.dependencies import (
get_logger,
get_app_tools,
get_f6_plugin_module,
get_action_map
)
from app.utils.app_tools import AppTools
# 创建路由器
# 使用 APIRouter 分离路由,便于管理和维护
router = APIRouter()
@router.get("/health", response_model=HealthResponse, tags=["系统"])
async def healthcheck():
"""
健康检查端点
用于检查服务是否正常运行
"""
return HealthResponse(status="ok", version="2.0.0")
@router.post("/webhook", response_model=WebhookResponse, tags=["业务"])
async def webhook(
request: Request,
logger: logging.Logger = Depends(get_logger),
app_tools: AppTools = Depends(get_app_tools),
f6_plugin_module = Depends(get_f6_plugin_module),
action_map: Dict[str, Any] = Depends(get_action_map)
):
"""
接受前端请求后将任务放入消息队列
此端点接收简道云插件的请求,根据请求头中的 Action 字段路由到相应的处理函数。
支持的操作包括:登录、获取公司信息、文件校验、品牌创建等。
Args:
request: FastAPI 请求对象,包含请求体和请求头
logger: 日志记录器
app_tools: 应用工具实例
f6_plugin_module: F6插件模块实例
action_map: 操作映射表
Returns:
WebhookResponse: 任务处理结果
Raises:
HTTPException: 当操作类型无效或任务执行超时时抛出
"""
try:
# 获取请求数据并验证
try:
raw_data = await request.json()
# 使用 Pydantic 进行数据验证(允许额外字段)
webhook_data = WebhookRequest(**raw_data)
data = webhook_data.model_dump(exclude_none=True)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请求体必须是有效的 JSON 格式"
)
except Exception as e:
logger.warning(f"请求数据验证失败: {str(e)}, 原始数据: {raw_data if 'raw_data' in locals() else 'N/A'}")
# 如果验证失败,仍然尝试使用原始数据(向后兼容)
data = raw_data if 'raw_data' in locals() else {}
# 获取并解码请求头
header = request.headers
decoded_header = app_tools.decode_headers(header)
# 验证 Action 字段(HTTP头在FastAPI中会被转换为小写)
# 同时检查 'Action' 和 'action' 以兼容不同情况
action = decoded_header.get('Action') or decoded_header.get('action')
if not action:
logger.warning(f"请求头中缺少 Action 字段,请求头: {decoded_header}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="请求头中缺少必需的 Action 字段"
)
# 处理 F6_Plugin 特殊逻辑
if action == 'F6_Plugin':
# 同时检查 'Check' 和 'check' 以兼容不同情况
check = decoded_header.get('Check') or decoded_header.get('check')
if check == '':
handler = f6_plugin_module.check_file
elif check == '':
sub_action = data.get('Action')
if not sub_action:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="F6_Plugin 操作需要提供 Action 字段"
)
handler = action_map.get(sub_action)
if not handler:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"未知的子操作类型: {sub_action}"
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"F6_Plugin 操作需要提供有效的 Check 字段(是/否),当前值: {check}"
)
else:
handler = action_map.get(action)
if not handler:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"未知的操作类型: {action}。支持的操作: {', '.join(action_map.keys())}"
)
logger.info(f"接收到操作请求: {action}, 数据ID: {data.get('data_id', 'N/A')}")
# 将任务放入消息队列
response_queue = app_tools.enqueue_task(handler, data)
# 等待任务处理结果(添加超时保护,简道云默认60秒)
try:
# 使用 asyncio.wait_for 添加超时
result = await asyncio.wait_for(
anyio.to_thread.run_sync(response_queue.get),
timeout=55.0
)
except asyncio.TimeoutError:
logger.error(f"任务执行超时: {action}, 数据ID: {data.get('data_id', 'N/A')}")
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="任务执行超时,请稍后重试"
)
# 验证返回结果格式
if not isinstance(result, dict):
result = {"msg": str(result)}
# 处理 msg 字段:如果 msg 是字典,将其内容展开到结果中
if "msg" in result and isinstance(result["msg"], dict):
msg_dict = result.pop("msg")
logger.warning(f"操作 {action} 返回的 msg 字段是字典类型,正在自动转换。原始数据: {json.dumps(msg_dict, ensure_ascii=False)}")
# 如果字典中有 msg 字段,使用它;否则使用 JSON 字符串
if "msg" in msg_dict:
result["msg"] = msg_dict.pop("msg")
else:
result["msg"] = json.dumps(msg_dict, ensure_ascii=False)
# 将字典中的其他字段合并到结果中
result.update(msg_dict)
if "msg" not in result:
result["msg"] = "操作完成"
# 确保 msg 是字符串类型
if not isinstance(result.get("msg"), str):
logger.warning(f"操作 {action} 返回的 msg 字段类型为 {type(result.get('msg'))},正在转换为字符串")
result["msg"] = str(result.get("msg", "操作完成"))
logger.info(f"操作完成: {action}, 结果: {json.dumps(result, ensure_ascii=False)}")
# 返回响应(使用 Pydantic 模型验证)
try:
return WebhookResponse(**result)
except ValidationError as validation_error:
# 捕获 Pydantic 验证错误,提供更清晰的错误信息
error_messages = []
for error in validation_error.errors():
field = " -> ".join(str(loc) for loc in error.get("loc", []))
error_type = error.get("type", "unknown")
error_msg = error.get("msg", "验证失败")
error_messages.append(f"字段 '{field}': {error_msg} (类型: {error_type})")
error_detail = "; ".join(error_messages)
logger.error(
f"响应数据验证失败 - 操作: {action}, "
f"错误详情: {error_detail}, "
f"原始数据: {json.dumps(result, ensure_ascii=False, default=str)}"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"响应数据格式错误: {error_detail}。请检查操作 '{action}' 的返回格式是否符合 API 规范(msg 字段必须是字符串类型)。"
)
except HTTPException:
# 重新抛出 HTTP 异常
raise
except Exception as e:
# 捕获其他未预期的异常
logger.error(f"处理请求时发生未预期的错误: {type(e).__name__} - {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"处理请求时发生错误: {str(e)}"
)
-24
View File
@@ -1,24 +0,0 @@
"""
后台任务模块 - 向后兼容入口
此文件保持向后兼容,实际功能已拆分到 app.tasks 模块中
"""
# 从新的 tasks 模块导入所有函数,保持向后兼容
from app.tasks import (
update_jiandaoyun,
approve_workflow,
create_brand_background,
delete_history_background,
delete_customer_background,
delete_car_background,
modify_customer_info_background,
)
__all__ = [
'update_jiandaoyun',
'approve_workflow',
'create_brand_background',
'delete_history_background',
'delete_customer_background',
'delete_car_background',
'modify_customer_info_background',
]
+31 -1
View File
@@ -1,29 +1,59 @@
"""
应用配置模块
本模块负责管理应用的所有配置项,包括:
- 目录路径配置
- API Token 配置
- 日志配置
注意:生产环境建议将敏感信息(如 API Token)移至环境变量。
"""
from pathlib import Path
# 获取当前文件所在的目录
# 当前文件位于 app/config.pyparent.parent 获取项目根目录
BASE_DIR = Path(__file__).resolve().parent.parent # 项目根目录
# 构建保存下载文件的目录路径
# 用于存储从简道云下载的文件
SAVE_DIRECTORY = BASE_DIR / '下载文件'
# 构建保存模板文件的目录路径
# 用于存储模板文件
MODE_DIRECTORY = BASE_DIR / '模板文件'
# 构建日志文件的目录路径
# 用于存储应用日志文件
LOGS_DIRECTORY = BASE_DIR / 'logs'
# 日志文件路径
LOG_FILE = LOGS_DIRECTORY / '简道云.log'
# 确保目录存在,如果不存在则创建
# 在应用启动时自动创建必要的目录
SAVE_DIRECTORY.mkdir(parents=True, exist_ok=True)
MODE_DIRECTORY.mkdir(parents=True, exist_ok=True)
# API 配置
# 简道云 API Token,用于调用简道云 API
# 注意:生产环境建议使用环境变量管理此配置
JIANDAOYUN_API_TOKEN = 'Bearer qygHulymo1fekJk4CIZyNKjyQAzG8CFN' # 曹伟应用api测试 app_key
# 导出配置
class Config:
"""
应用配置类
统一管理应用的所有配置项,方便在应用中使用。
属性:
BASE_DIR: 项目根目录路径
SAVE_DIRECTORY: 下载文件保存目录
MODE_DIRECTORY: 模板文件保存目录
JIANDAOYUN_API_TOKEN: 简道云 API Token
LOGS_DIRECTORY: 日志文件目录
LOG_FILE: 日志文件路径
"""
BASE_DIR = BASE_DIR
SAVE_DIRECTORY = SAVE_DIRECTORY
MODE_DIRECTORY = MODE_DIRECTORY
+16 -5
View File
@@ -1,17 +1,28 @@
"""
核心模块初始化
统一初始化请求头管理器、模块注册表等核心组件
本模块统一初始化和管理核心组件,包括:
- ModuleRegistry: 模块注册表
- CoreManager: 核心管理器
提供统一的接口来管理这些核心组件。
"""
from typing import Dict, Any, Callable
from app.core.header_manager import HeaderManager
from app.core.module_registry import ModuleRegistry, registry
class CoreManager:
"""核心管理器 - 统一管理所有核心组件"""
"""
核心管理器
统一管理所有核心组件,提供便捷的方法来初始化和注册模块。
属性:
registry: 模块注册表实例
"""
def __init__(self):
self.header_manager = HeaderManager
"""初始化核心管理器"""
self.registry = registry
def initialize_modules(self, modules: Dict[str, Any]):
@@ -49,12 +60,12 @@ class CoreManager:
# 全局核心管理器实例
# 在应用启动时使用此实例来注册模块和操作
core_manager = CoreManager()
# 导出常用类和函数
__all__ = [
'core_manager',
'HeaderManager',
'ModuleRegistry',
'registry',
]
-118
View File
@@ -1,118 +0,0 @@
"""
请求头管理器
统一管理不同模块的请求头配置
"""
from typing import Dict, Optional
from dataclasses import dataclass, field
@dataclass
class HeaderConfig:
"""请求头配置"""
referer: Optional[str] = None
user_agent: Optional[str] = None
content_type: Optional[str] = None
authorization: Optional[str] = None
custom_headers: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Dict[str, str]:
"""转换为字典格式"""
headers = {}
if self.referer:
headers['Referer'] = self.referer
if self.user_agent:
headers['User-Agent'] = self.user_agent
if self.content_type:
headers['Content-Type'] = self.content_type
if self.authorization:
headers['Authorization'] = self.authorization
# 添加自定义请求头
headers.update(self.custom_headers)
return headers
class HeaderManager:
"""请求头管理器"""
# 默认请求头配置
DEFAULT_HEADERS = HeaderConfig(
referer='https://yunxiu.f6car.cn/erp/view/index.html',
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0'
)
# F6系统登录请求头
F6_LOGIN_HEADERS = HeaderConfig(
referer='https://yunxiu.f6car.com/kzf6/login/confirm',
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/129.0.0.0'
)
# 简道云API请求头
JIANDAOYUN_API_HEADERS = HeaderConfig(
content_type='application/json',
# authorization 应该从配置中获取,这里只是示例
)
# 模块特定的请求头配置
_module_headers: Dict[str, HeaderConfig] = {
'default': DEFAULT_HEADERS,
'f6_login': F6_LOGIN_HEADERS,
'jiandaoyun_api': JIANDAOYUN_API_HEADERS,
}
@classmethod
def get_headers(cls, module_name: str = 'default', **overrides) -> Dict[str, str]:
"""
获取指定模块的请求头
Args:
module_name: 模块名称
**overrides: 覆盖的请求头配置
Returns:
请求头字典
"""
# 获取基础配置
config = cls._module_headers.get(module_name, cls.DEFAULT_HEADERS)
# 创建配置副本
header_config = HeaderConfig(
referer=overrides.get('referer', config.referer),
user_agent=overrides.get('user_agent', config.user_agent),
content_type=overrides.get('content_type', config.content_type),
authorization=overrides.get('authorization', config.authorization),
custom_headers={**config.custom_headers, **overrides.get('custom_headers', {})}
)
return header_config.to_dict()
@classmethod
def register_module_headers(cls, module_name: str, config: HeaderConfig):
"""
注册模块的请求头配置
Args:
module_name: 模块名称
config: 请求头配置
"""
cls._module_headers[module_name] = config
@classmethod
def update_module_headers(cls, module_name: str, **updates):
"""
更新模块的请求头配置
Args:
module_name: 模块名称
**updates: 要更新的配置项
"""
if module_name in cls._module_headers:
config = cls._module_headers[module_name]
for key, value in updates.items():
if hasattr(config, key):
setattr(config, key, value)
else:
config.custom_headers[key] = value
+38 -5
View File
@@ -1,6 +1,12 @@
"""
模块注册和路由管理
提供统一的模块注册机制,方便添加新功能模块
模块注册表模块
本模块提供统一的模块注册机制,用于管理所有业务模块和操作。
使用注册表模式可以:
- 统一管理所有操作
- 方便添加新功能模块
- 支持动态路由和操作查找
- 提供操作元数据管理
"""
from typing import Dict, Callable, Optional, Any
from dataclasses import dataclass
@@ -8,7 +14,18 @@ from dataclasses import dataclass
@dataclass
class ActionConfig:
"""操作配置"""
"""
操作配置数据类
存储操作的配置信息,包括处理函数、所属模块、描述等。
属性:
handler: 处理函数,执行具体业务逻辑
module_name: 所属模块名称
description: 操作描述,用于文档和日志
requires_auth: 是否需要认证,默认 True
header_module: 使用的请求头模块名称,可选
"""
handler: Callable # 处理函数
module_name: str # 所属模块名称
description: Optional[str] = None # 描述
@@ -17,9 +34,19 @@ class ActionConfig:
class ModuleRegistry:
"""模块注册表"""
"""
模块注册表类
统一管理所有业务模块和操作的注册表。
支持注册模块实例和操作,并提供查询功能。
属性:
_actions: 操作字典,格式为 {操作名: ActionConfig}
_modules: 模块字典,格式为 {模块名: 模块信息}
"""
def __init__(self):
"""初始化模块注册表"""
self._actions: Dict[str, ActionConfig] = {}
self._modules: Dict[str, Dict[str, Any]] = {}
@@ -65,7 +92,12 @@ class ModuleRegistry:
return self._actions.get(action_name)
def get_all_actions(self) -> Dict[str, ActionConfig]:
"""获取所有注册的操作"""
"""
获取所有注册的操作
Returns:
Dict[str, ActionConfig]: 所有注册的操作字典的副本
"""
return self._actions.copy()
def register_module(self, module_name: str, module_instance: Any, **metadata):
@@ -113,4 +145,5 @@ class ModuleRegistry:
# 全局模块注册表实例
# 在应用启动时使用此实例来注册所有模块和操作
registry = ModuleRegistry()
+377 -35
View File
@@ -1,26 +1,69 @@
"""
F6 后台执行模块
本模块提供 F6 插件相关的功能,包括:
- 文件上传和校验
- 品牌批量创建
- 历史记录删除
- 客户信息管理
- 车辆信息管理
- 项目信息批量启停
- 材料信息批量修改
- 项目信息批量修改
依赖:
- requests: HTTP 请求
- pandas: Excel 文件处理
- threading: 后台任务处理
"""
import logging
import traceback
import requests
from urllib.parse import quote
import pandas as pd
import os
import urllib.parse
from datetime import datetime
from app.api import API
from typing import Optional, Dict, Any, Tuple
from app.config import Config
from app.module import F6Module
import threading
from app import back_ground_tasks
from app.api import API
from app.config import Config
from app.module.module import F6Module
from app.tasks.brand_tasks import create_brand_background
from app.tasks.delete_tasks import (
delete_history_background,
delete_customer_background,
delete_car_background
)
from app.tasks.material_tasks import (
batch_disable_projects,
batch_modify_materials,
batch_modify_projects
)
from app.tasks.customer_tasks import modify_customer_info_background
from app.tasks.bi_tasks import bi_task_background
# 简道云 API 实例,用于调用简道云 API
api_instance = API()
logger = logging.getLogger('app')
class F6PluginModule:
"""
F6 插件模块类
提供 F6 插件相关的所有功能,包括文件处理、品牌管理、数据删除等。
"""
@staticmethod
def accept_file(data: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]: # 接收文件
def accept_file(data: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any]]:
"""
接收文件
接收文件
处理前端上传的文件,下载文件并保存到指定目录。
此方法用于处理前端上传的文件,下载文件并保存到指定目录。主要步骤包括:
1. 处理前端传递的数据,获取文件的URL。
2. 解析URL以获取文件名。
@@ -34,26 +77,26 @@ class F6PluginModule:
Returns:
tuple: 包含文件保存路径和处理后的数据的元组。如果文件保存成功,返回保存路径和数据;如果失败,返回 None 和数据。
"""
data = api_instance.entry_data_get(data=data)
data = api_instance.entry_data_get(data=data, replace=True)
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}')
@@ -87,8 +130,7 @@ class F6PluginModule:
else:
return None, data
def check_file(self, data: Dict[str, Any]) -> Dict[str, str]: # 校验上传文件
def check_file(self, data: Dict[str, Any]) -> dict[str, str] | None: # 校验上传文件
"""
校验上传文件。
@@ -112,7 +154,7 @@ class F6PluginModule:
# 安全地获取 Action 字段
data_dict = data1.get('data', {})
action = data_dict.get('Action(隐藏)')
if not action:
return {'msg': '缺少Action字段,无法校验文件'}
@@ -134,8 +176,38 @@ class F6PluginModule:
else:
print("'msg':'文件上传格式错误'")
return {'msg': '文件上传格式错误'}
elif action == 'delete_cars':
pass
elif action == 'disable_project':
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 == 'batch_modify_materials':
df2 = pd.read_excel(save_path, sheet_name=0)
required_columns = {'原材料编码', '新材料编码', '品牌', '名称', '规格'}
actual_columns = set(df2.columns)
if required_columns.issubset(actual_columns):
print('文件校验成功')
return {'msg': f'{save_path}', 'check': ''}
else:
missing = required_columns - actual_columns
print(f"文件上传格式错误:缺少列 {missing}")
return {'msg': '文件上传格式错误'}
elif action == 'batch_modify_projects':
df3 = pd.read_excel(save_path, sheet_name=0)
required_columns = {'原项目编码', '新项目编码', '项目名称', '业务分类', '销项税率', '项目说明',
}
actual_columns = set(df3.columns)
if required_columns.issubset(actual_columns):
print('文件校验成功')
return {'msg': f'{save_path}', 'check': ''}
else:
missing = required_columns - actual_columns
print(f"文件上传格式错误:缺少列 {missing}")
return {'msg': '文件上传格式错误'}
else:
pass
@@ -145,10 +217,21 @@ class F6PluginModule:
else:
return {'msg': '当前节点无附件上传', 'check': ''}
@staticmethod
def create_brand(data: Dict[str, Any]) -> Dict[str, str]: # 创建品牌
entry_data = api_instance.entry_data_get(data=data)
def create_brand(data: Dict[str, Any]) -> Dict[str, str]:
"""
创建品牌
从简道云获取品牌创建请求,读取 Excel 文件,并在后台线程中批量创建品牌。
立即返回"正在执行"的提示,实际创建在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
"""
entry_data = api_instance.entry_data_get(data=data, replace=True)
print('执行 品牌批量新建')
username = entry_data['data']['账号']
password = entry_data['data']['密码']
@@ -167,7 +250,7 @@ class F6PluginModule:
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
try:
thread = threading.Thread(target=back_ground_tasks.create_brand_background,
thread = threading.Thread(target=create_brand_background,
args=(data, cookies, df, save_path))
thread.start()
except Exception as e:
@@ -177,7 +260,19 @@ class F6PluginModule:
@staticmethod
def delete_history(data: Dict[str, Any]) -> Dict[str, str]:
entry_data = api_instance.entry_data_get(data=data)
"""
删除历史记录
从简道云获取删除历史记录请求,在后台线程中删除指定门店的历史维修记录。
立即返回"正在执行中"的提示,实际删除在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典
"""
entry_data = api_instance.entry_data_get(data=data, replace=True)
username = entry_data['data']['账号']
password = entry_data['data']['密码']
company_name = entry_data['data']['公司名称']
@@ -202,7 +297,7 @@ class F6PluginModule:
org_id = org['orgId']
if org_id:
thread = threading.Thread(target=back_ground_tasks.delete_history_background,
thread = threading.Thread(target=delete_history_background,
args=(data, cookies, org_id, org_name1))
thread.start()
return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'}
@@ -211,24 +306,40 @@ class F6PluginModule:
@staticmethod
def delete_customer(data):
entry_data = api_instance.entry_data_get(data=data)
"""
删除客户
从简道云获取删除客户请求,在后台线程中批量删除客户信息。
立即返回"正在执行中"的提示,实际删除在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典
"""
print('执行 删除客户')
entry_data = api_instance.entry_data_get(data=data, replace=True)
username = entry_data['data']['账号']
password = entry_data['data']['密码']
company_name = entry_data['data']['公司名称']
res = F6Module.login_in(username, password, company_name)
print(res.json())
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"
url = "https://yunxiu.f6car.cn/member/customer/listForPermission?pageSize=100&pageNo=1"
res = requests.get(url, cookies=cookies)
json = res.json()
total = res.json().get('data', {}).get('total', 0)
if json:
thread = threading.Thread(target=back_ground_tasks.delete_customer_background,
args=(data, cookies, json['data']['data'],))
if total:
total = int(total)
thread = threading.Thread(target=delete_customer_background,
args=(data, cookies, total,))
thread.start()
return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'}
return {'msg': '正在执行中',
'msg_details': f'总计{total}条数据,8-20点3.5s一条数据,其余时间1.5s一条数据'}
else:
return {'msg': '未执行', 'msg_details': '无客户信息'}
else:
@@ -236,7 +347,19 @@ class F6PluginModule:
@staticmethod
def delete_cars(data):
entry_data = api_instance.entry_data_get(data=data)
"""
删除车辆
从简道云获取删除车辆请求,在后台线程中批量删除客户车辆信息。
立即返回"正在执行中"的提示,实际删除在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典
"""
entry_data = api_instance.entry_data_get(data=data, replace=True)
username = entry_data['data']['账号']
password = entry_data['data']['密码']
company_name = entry_data['data']['公司名称']
@@ -259,7 +382,7 @@ class F6PluginModule:
all_page = total_items // 100 + (total_items % 100 > 0)
if res_data:
thread = threading.Thread(target=back_ground_tasks.delete_car_background,
thread = threading.Thread(target=delete_car_background,
args=(data, url, cookies, header, all_page))
thread.start()
return {'msg': '正在执行中', 'msg_details': '8-20点3.5s一条数据,其余时间1.5s一条数据'}
@@ -269,8 +392,21 @@ class F6PluginModule:
else:
return {'msg': '未执行', 'msg_details': '登录失败'}
def modify_customer_info(self, data: Dict[str, str]):
entry_data = api_instance.entry_data_get(data=data)
@staticmethod
def modify_customer_info(data: Dict[str, str]):
"""
修改客户信息
从简道云获取修改客户信息请求,读取 Excel 文件,并在后台线程中批量修改客户信息。
立即返回"正在执行中"的提示,实际修改在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典
"""
entry_data = api_instance.entry_data_get(data=data, replace=True)
username = entry_data['data']['账号']
password = entry_data['data']['密码']
company_name = entry_data['data']['公司名称']
@@ -288,11 +424,217 @@ class F6PluginModule:
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
if cookies:
thread = threading.Thread(target=back_ground_tasks.modify_customer_info_background,
thread = threading.Thread(target=modify_customer_info_background,
args=(data, cookies, df, save_path))
thread.start()
return {'msg': '正在执行中', 'msg_details': '请稍后查看结果'}
else:
return {'msg': '未执行', 'msg_details': 'cookies获取失败'}
@staticmethod
def disable_projects(data: Dict[str, Any]) -> Dict[str, str]:
"""
项目批量启停
从简道云获取项目批量启停请求,读取 Excel 文件,并在后台线程中批量启停项目。
立即返回"正在执行"的提示,实际创建在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
"""
try:
entry_data = api_instance.entry_data_get(data=data, replace=True)
print('执行 项目批量停用/启用')
username = entry_data['data']['账号']
password = entry_data['data']['密码']
company_name = entry_data['data']['公司名称']
save_path = entry_data['data']['文件保存地址']
option = entry_data['data']['项目材料批量操作']
login_response = F6Module.login_in(username, password, company_name)
if login_response is None:
logger.error(f"F6系统登录失败,用户名: {username}")
return {'msg': '登录失败'}
try:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
except Exception as e:
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
logger.info("当前登录cookies:{}".format(cookies))
try:
thread = threading.Thread(target=batch_disable_projects,
args=(data, cookies, df, save_path, option))
thread.start()
except Exception as e:
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
print(f'创建线程失败: {str(e)}')
return {'msg': f'创建后台线程失败: {str(e)}'}
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
except KeyError as e:
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'数据字段缺失: {str(e)}'}
except Exception as e:
logger.error(f"项目批量启停任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'执行失败: {str(e)}'}
@staticmethod
def modify_material(data: Dict[str, Any]) -> Dict[str, str]:
"""
材料批量修改
从简道云获取材料批量修改请求,读取 Excel 文件,并在后台线程中批量修改材料。
立即返回"正在执行"的提示,实际创建在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
"""
try:
entry_data = api_instance.entry_data_get(data=data, replace=True)
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:
logger.error(f"F6系统登录失败,用户名: {username}")
return {'msg': '登录失败'}
try:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
except Exception as e:
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
try:
thread = threading.Thread(target=batch_modify_materials,
args=(data, cookies, df, save_path))
thread.start()
except Exception as e:
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
print(f'创建线程失败: {str(e)}')
return {'msg': f'创建后台线程失败: {str(e)}'}
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
except KeyError as e:
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'数据字段缺失: {str(e)}'}
except Exception as e:
logger.error(f"材料批量修改任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'执行失败: {str(e)}'}
@staticmethod
def modify_project(data: Dict[str, Any]) -> Dict[str, str]:
"""
项目批量修改
从简道云获取项目批量修改请求,读取 Excel 文件,并在后台线程中批量修改项目。
立即返回"正在执行"的提示,实际创建在后台线程中执行。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
"""
try:
entry_data = api_instance.entry_data_get(data=data, replace=True)
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:
logger.error(f"F6系统登录失败,用户名: {username}")
return {'msg': '登录失败'}
try:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
except Exception as e:
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
try:
thread = threading.Thread(target=batch_modify_projects,
args=(data, cookies, df, save_path))
thread.start()
except Exception as e:
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
print(f'创建线程失败: {str(e)}')
return {'msg': f'创建后台线程失败: {str(e)}'}
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
except KeyError as e:
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'数据字段缺失: {str(e)}'}
except Exception as e:
logger.error(f"项目批量修改任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
return {'msg': f'执行失败: {str(e)}'}
@staticmethod
def bi_task(data: Dict[str, Any]) -> Dict[str, str]:
"""
BI任务(示例)
从简道云获取BI任务请求,读取 Excel 文件(如果需要),并在后台线程中执行BI任务。
立即返回"正在执行"的提示,实际执行在后台线程中完成。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
"""
entry_data = api_instance.entry_data_get(data=data, replace=True)
print('执行 BI任务')
# 获取必要的参数(根据实际需求调整)
username = entry_data['data'].get('账号')
password = entry_data['data'].get('密码')
company_name = entry_data['data'].get('公司名称')
save_path = entry_data['data'].get('文件保存地址')
# 如果需要登录F6系统
cookies = None
if username and password and company_name:
login_response = F6Module.login_in(username, password, company_name)
if login_response is None:
return {'msg': '登录失败', 'msg_details': '无法登录F6系统'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
# 如果需要读取Excel文件
df = None
if save_path:
try:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
except Exception as e:
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
# 启动后台线程执行BI任务
try:
thread = threading.Thread(target=bi_task_background,
args=(data, cookies, df, save_path))
thread.start()
except Exception as e:
print(f'创建线程失败: {str(e)}')
return {'msg': '任务启动失败', 'msg_details': f'无法启动后台任务: {str(e)}'}
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
+8
View File
@@ -1,2 +1,10 @@
"""
业务模块包
本包包含所有业务模块,包括:
- module.py: F6Module - F6系统相关功能
- f6_plugin_module.py: F6PluginModule - F6插件功能
- other_module.py: OtherPluginModule - 其他功能模块
"""
__all__ = []
+96 -1
View File
@@ -1,3 +1,18 @@
"""
F6 前端即时响应模块
本模块提供 F6 系统相关的功能,包括:
- 登录和认证
- 验证码识别
- 公司信息获取
- 门店信息获取
- 保持连接
依赖:
- requests: HTTP 请求
- PIL: 图像处理
- pytesseract: OCR 识别
"""
import requests
import hashlib
from urllib.parse import quote
@@ -7,15 +22,33 @@ from typing import Optional, Dict, AnyStr
from PIL import Image, ImageEnhance
import pytesseract
import logging
from datetime import datetime
# 简道云 API 实例,用于调用简道云 API
api_instance = API()
# 日志记录器
logger = logging.getLogger('app')
class F6Module:
"""
F6 系统模块类
提供 F6 系统相关的所有功能,包括登录、信息获取等。
"""
@staticmethod
def get_captcha() -> AnyStr:
"""
获取并识别验证码
从 F6 系统获取验证码图片,使用 OCR 识别验证码文本。
Returns:
AnyStr: 识别出的验证码文本
注意:
需要系统安装 Tesseract OCR 才能正常工作
"""
captcha_url = 'https://yunxiu.f6car.cn/kzf6/login/captcha-image'
response = requests.get(captcha_url)
with open('captcha.png', 'wb') as f:
@@ -37,6 +70,23 @@ class F6Module:
@staticmethod
def login_in(username: str, password: str, company_name: str = '默认门店',) -> Optional[requests.Response]:
"""
F6 系统登录
使用用户名和密码登录 F6 系统,并选择指定的公司。
如果触发验证码,会自动识别并重试登录。
Args:
username: 用户名
password: 密码(明文,方法内部会进行 MD5 加密)
company_name: 公司名称,默认为'默认门店'
Returns:
Optional[requests.Response]: 登录响应对象,登录失败返回 None
注意:
密码会在方法内部进行 MD5 加密处理
"""
url = "https://yunxiu.f6car.com/kzf6/login/confirm"
session = requests.Session()
header = {
@@ -82,6 +132,17 @@ class F6Module:
return None
def accept_login_message(self, data: Dict[str, str]) -> Dict[str, str]:
"""
接受登录消息并处理
处理简道云插件发送的登录请求,执行登录并返回结果。
Args:
data: 包含用户名、密码、公司名称的字典
Returns:
Dict[str, str]: 登录结果,包含状态信息
"""
username = data['username']
password = data['password']
company_name = data['company_name']
@@ -110,6 +171,17 @@ class F6Module:
return {"status": "登录失败,请检查公司名称"}
def get_company_information(self, data: Dict[str, str]) -> Dict[str, str]:
"""
获取公司信息
根据用户名和密码获取 F6 系统中的公司信息,并将结果保存到简道云。
Args:
data: 包含用户名、密码的字典
Returns:
Dict[str, str]: 包含时间戳的消息,用于后续查询
"""
username = data['username']
password = data['password']
timestamp = datetime.now().strftime("%Y-%m-%d %H-%M-%S")
@@ -173,6 +245,18 @@ class F6Module:
return res
def get_store_information(self, data: Dict[str, str]) -> Dict[str, dict[str, str]]:
"""
获取门店信息
根据用户名、密码和公司名称获取 F6 系统中的门店信息,
包括门店列表、客户车辆数量、客户数量等。
Args:
data: 包含用户名、密码、公司名称的字典
Returns:
Dict[str, dict[str, str]]: 包含时间戳、门店信息、统计数据的结果
"""
username = data['username']
password = data['password']
company_name = data['company_name']
@@ -221,6 +305,17 @@ class F6Module:
@staticmethod
def get_keep_heart(data: Dict[str, str]) -> Dict[str, str]:
"""
保持连接
用于保持连接的心跳检测,直接返回接收到的数据。
Args:
data: 接收到的数据字典
Returns:
Dict[str, str]: 原样返回接收到的数据
"""
return data
+18 -14
View File
@@ -1,22 +1,26 @@
import requests
from urllib.parse import quote
import pandas as pd
import os
import urllib.parse
from datetime import datetime
from app.api import API
from typing import Optional, Dict, Any, Tuple
from app.config import Config
from app.module import F6Module
import threading
from app import back_ground_tasks
"""
其他插件模块
api_instance = API()
本模块提供其他插件相关的功能,目前包括短信签名状态查询等功能。
"""
class OtherPluginModule:
"""
其他插件模块类
提供其他插件相关的功能,如短信签名状态等。
"""
def sms_signature_status(self):
"""
短信签名状态
查询短信签名状态(待实现)。
Returns:
待实现
"""
pass
+77
View File
@@ -0,0 +1,77 @@
"""
Pydantic 数据模型定义
用于 FastAPI 请求和响应的数据验证
"""
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field
from enum import Enum
class ActionType(str, Enum):
"""支持的操作类型枚举"""
LOGIN_IN = "login_in"
GET_COMPANY_INFORMATION = "get_company_information"
GET_STORE_INFORMATION = "get_store_information"
KEEP_ALIVE = "keep_alive"
CHECK_FILE = "check_file"
CREATE_BRAND = "create_brand"
DELETE_HISTORY = "delete_history"
DELETE_CUSTOMER = "delete_customer"
DELETE_CARS = "delete_cars"
SMS_SIGNATURE_STATUS = "sms_signature_status"
MODIFY_CUSTOMER_INFO = "modify_customer_info"
F6_PLUGIN = "F6_Plugin"
class WebhookRequest(BaseModel):
"""Webhook 请求体数据模型"""
# 通用字段
api_key: Optional[str] = Field(None, description="简道云应用ID")
entry_id: Optional[str] = Field(None, description="简道云表单ID")
data_id: Optional[str] = Field(None, description="简道云数据ID")
Action: Optional[str] = Field(None, description="操作类型")
# 登录相关字段
username: Optional[str] = Field(None, description="用户名")
password: Optional[str] = Field(None, description="密码")
company_name: Optional[str] = Field(None, description="公司名称")
# 文件相关字段
file_path: Optional[str] = Field(None, description="文件保存路径")
# 其他字段(允许任意额外字段)
class Config:
extra = "allow" # 允许额外字段,因为简道云可能传递其他字段
class WebhookHeader(BaseModel):
"""Webhook 请求头数据模型"""
Action: Optional[str] = Field(None, description="操作类型")
Check: Optional[str] = Field(None, description="检查标志(是/否)")
class Config:
extra = "allow" # 允许额外请求头
class WebhookResponse(BaseModel):
"""Webhook 响应数据模型"""
msg: str = Field(..., description="响应消息")
msg_details: Optional[str] = Field(None, description="详细信息")
check: Optional[str] = Field(None, description="检查结果")
status: Optional[str] = Field(None, description="状态")
class Config:
extra = "allow" # 允许额外字段
class HealthResponse(BaseModel):
"""健康检查响应模型"""
status: str = Field("ok", description="服务状态")
version: Optional[str] = Field(None, description="服务版本")
class ErrorResponse(BaseModel):
"""错误响应模型"""
detail: str = Field(..., description="错误详情")
error_code: Optional[str] = Field(None, description="错误代码")
-89
View File
@@ -1,89 +0,0 @@
# 后台任务模块结构说明
## 模块结构
后台任务已按功能拆分为以下模块:
```
app/tasks/
├── __init__.py # 统一导出入口
├── common.py # 通用功能模块(简道云表单更新、工作流审批)
├── brand_tasks.py # 品牌相关任务
├── delete_tasks.py # 删除相关任务
└── customer_tasks.py # 客户相关任务
```
## 模块说明
### common.py - 通用功能模块
包含所有任务共用的功能:
- `update_jiandaoyun()` - 更新简道云表单
- `approve_workflow()` - 工作流审批
### brand_tasks.py - 品牌任务模块
品牌相关的后台任务:
- `create_brand_background()` - 品牌批量创建
### delete_tasks.py - 删除任务模块
删除相关的后台任务:
- `delete_history_background()` - 删除历史维修记录
- `delete_customer_background()` - 删除客户信息
- `delete_car_background()` - 删除客户车辆信息
### customer_tasks.py - 客户任务模块
客户相关的后台任务:
- `modify_customer_info_background()` - 修改客户信息
## 向后兼容
原有的 `app.back_ground_tasks` 模块仍然可用,它现在作为向后兼容的入口,实际功能已拆分到 `app.tasks` 模块中。
## 添加新功能模块
如需添加新的功能模块,请按以下步骤:
1.`app/tasks/` 目录下创建新的模块文件,例如 `new_feature_tasks.py`
2. 在新模块中实现相关功能函数
3.`app/tasks/__init__.py` 中导入并导出新函数
4.`app/back_ground_tasks.py` 中导入新函数以保持向后兼容
示例:
```python
# app/tasks/new_feature_tasks.py
from app.tasks.common import update_jiandaoyun, approve_workflow
def new_feature_background(data, cookies):
# 实现新功能
result = "执行结果"
msg = update_jiandaoyun(data, result)
if msg.get('msg'):
approve_workflow(data)
```
```python
# app/tasks/__init__.py 中添加
from app.tasks.new_feature_tasks import new_feature_background
__all__ = [
# ... 其他函数
'new_feature_background',
]
```
## 使用方式
### 方式一:使用新的模块结构(推荐)
```python
from app.tasks import create_brand_background
from app.tasks.brand_tasks import create_brand_background # 也可以直接导入
```
### 方式二:使用向后兼容的导入方式
```python
from app import back_ground_tasks
back_ground_tasks.create_brand_background(...)
```
两种方式都可以正常工作,代码执行逻辑完全一致。
+24 -2
View File
@@ -1,6 +1,14 @@
"""
后台任务模块统一导出入口
保持向后兼容,所有原有导入方式仍然有效
本模块统一导出所有后台任务函数,保持向后兼容。
所有原有导入方式仍然有效。
导出的任务包括:
- 通用功能: update_jiandaoyun, approve_workflow
- 品牌任务: create_brand_background
- 删除任务: delete_history_background, delete_customer_background, delete_car_background
- 客户任务: modify_customer_info_background
"""
# 通用功能
from app.tasks.common import update_jiandaoyun, approve_workflow
@@ -18,6 +26,15 @@ from app.tasks.delete_tasks import (
# 客户相关任务
from app.tasks.customer_tasks import modify_customer_info_background
# BI相关任务
from app.tasks.bi_tasks import bi_task_background
from app.tasks.material_tasks import ( \
batch_modify_projects,
batch_modify_materials,
batch_disable_projects
)
__all__ = [
# 通用功能
'update_jiandaoyun',
@@ -30,5 +47,10 @@ __all__ = [
'delete_car_background',
# 客户任务
'modify_customer_info_background',
# BI任务
'bi_task_background',
# 项目材料任务
'batch_disable_projects',
'batch_modify_materials',
'batch_modify_projects',
]
+86
View File
@@ -0,0 +1,86 @@
"""
BI相关后台任务模块
本模块包含BI相关的后台任务,包括:
- TODO BI数据处理
这些任务在后台线程中执行,不会阻塞主请求。
执行完成后会更新简道云表单并自动提交工作流。
"""
import logging
import os
import requests
import pandas as pd
from typing import Dict, Any
from tqdm import tqdm
from app.tasks.common import update_jiandaoyun, approve_workflow
logger = logging.getLogger('app')
def bi_task_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame = None, save_path: str = None):
"""
BI任务后台执行函数
在后台线程中执行BI相关任务,如数据处理、报表生成等。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息(如果需要)
df: Excel 文件读取的内容,DataFrame 格式(如果需要)
save_path: Excel 文件保存的地址,执行完成后会删除此文件(如果需要)
Returns:
None
注意:
- 这是一个示例函数,需要根据实际BI任务需求进行实现
- 执行完成后会自动删除上传的文件(如果提供了save_path)
- 执行结果会更新到简道云表单
"""
try:
# TODO: 在这里实现具体的BI任务逻辑
# 例如:数据处理、报表生成、数据同步等
# 示例:处理数据
results = []
if df is not None:
df = df.where(pd.notnull(df), None)
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理BI数据"):
# TODO: 实现具体的数据处理逻辑
# 例如:调用BI API、生成报表、数据转换等
result_item = {
'行号': index + 1,
'状态': '处理成功'
}
results.append(result_item)
else:
# 如果没有DataFrame,执行其他BI任务
# TODO: 实现其他BI任务逻辑
results.append({'状态': 'BI任务执行成功'})
# 删除文件(如果提供了save_path)
if save_path and os.path.exists(save_path):
os.remove(save_path)
logger.info(f'{save_path}已删除')
# 格式化结果
results_str = f'{results}' if results else 'BI任务执行完成'
logger.info(f"BI任务执行结果: {results_str}")
# 调用api回写改掉 执行明细与执行状态
msg = update_jiandaoyun(data, results_str)
if msg.get('msg'):
approve_workflow(data)
logger.info('表单已自动提交至下一步')
except Exception as e:
error_msg = f'BI任务执行失败: {str(e)}'
logger.error(error_msg, exc_info=True)
msg = update_jiandaoyun(data, error_msg)
if msg.get('msg'):
approve_workflow(data)
+23 -7
View File
@@ -1,6 +1,10 @@
"""
品牌相关后台任务模块
包含品牌批量创建等功能
本模块包含品牌相关的后台任务,包括:
- 品牌批量创建
这些任务在后台线程中执行,不会阻塞主请求。
"""
import logging
import os
@@ -15,12 +19,24 @@ logger = logging.getLogger('app')
def create_brand_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str):
"""
品牌批量创建后台运行函数
:param data: 包含表单id、数据id等的字典
:param cookies: 用户登录f6系统的cookies信息
:param df: 表格读取到的内容,DataFrame格式
:param save_path: 文件保存的地址
:return: None
品牌批量创建后台任务
在后台线程中批量创建品牌,从 Excel 文件中读取品牌名称并创建。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
df: Excel 文件读取的内容,DataFrame 格式,第一列为品牌名称
save_path: Excel 文件保存的地址,执行完成后会删除此文件
Returns:
None
注意:
- 无效的品牌名(None、空字符串)会被跳过
- 执行完成后会自动删除上传的文件
- 执行结果会更新到简道云表单
"""
df = df.where(pd.notnull(df), None)
# 定义请求URL
+173 -14
View File
@@ -1,10 +1,19 @@
"""
通用后台任务模块
包含简道云表单更新和工作流审批等通用功能
本模块包含所有后台任务通用的功能,包括:
- 简道云表单更新
- 工作流审批
- 获取门店ID
- 获取会员卡列表
这些功能被多个后台任务模块复用。
"""
import logging
import time
from typing import Dict, Any
import requests
from typing import Dict, Any, List, Optional, Callable
from tqdm import tqdm
from app.api import API
api_instance = API()
@@ -14,9 +23,15 @@ logger = logging.getLogger('app')
def update_jiandaoyun(data: Dict[str, Any], results: str):
"""
更新简道云表单
:param data: 包含表单id、应用id、数据id的字典
:param results: 执行结果信息
:return: 更新结果字典
将后台任务的执行结果更新到简道云表单中。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
results: 执行结果信息,将写入到表单的执行明细字段
Returns:
Dict: 更新结果字典,{'msg': True} 表示成功,{'msg': False} 表示失败
"""
# 定义简道云数据配置
jiandaoyun_data = {
@@ -44,42 +59,51 @@ def update_jiandaoyun(data: Dict[str, Any], results: str):
def approve_workflow(data: Dict[str, Any]):
"""
获取简道云当前流程节点并直接提交
:param data: 包含表单id、应用id、数据id的字典
:return: None
获取简道云工作流的当前待处理任务,并自动提交到下一步。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
None
注意:
如果未找到待处理任务,函数会记录错误并返回,不会抛出异常
"""
# 获取简道云当前流程列表
json = api_instance.workflow_instance_get(data)
# 检查返回数据是否有效
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,
@@ -92,3 +116,138 @@ def approve_workflow(data: Dict[str, Any]):
except Exception as e:
logger.error(f"简道云工作流任务提交失败: {e}")
def execute_failure_handler(data: Dict[str, Any]):
"""
简道云失败流程通知
函数执行失败时调用,通过钉钉webhook通知到指定人员
"""
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
pay_load = {
"api_key": "6694d3c4fcb69ca9a111a6c4",
"entry_id": "6938e011b360a1132522a62a",
"data": {
"_widget_1765335060501": {"value": now}, # 失败时间
"_widget_1765335060502": {"value": data['failure_name']}, # 任务名称
"_widget_1765335060503": {"value": data['failure_details']} # 失败明细
}
}
api_instance.data_batch_create(pay_load)
def get_operate_org_id(cookies: Dict[str, str], data: Dict[str, str] = None) -> Optional[str]:
"""
获取操作门店ID
从F6系统获取第一个门店的组织ID,用于后续操作。
Args:
cookies: 用户登录 F6 系统的 cookies 信息
data:数据id等
Returns:
Optional[str]: 门店ID,如果获取失败返回 None
注意:
如果未获取到门店信息或门店ID为空,会记录错误日志并返回 None
"""
org_url = "https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=100&name="
try:
org_res = requests.get(url=org_url, cookies=cookies)
logger.info(org_res.json())
org_data = org_res.json().get("data", {})
org_list = org_data.get("list", [])
if not org_list or len(org_list) == 0:
logger.error("未获取到门店信息")
return None
if data:
entry_data = api_instance.entry_data_get(data=data, replace=True)
org_name = entry_data.get("data", {}).get("门店名称")
operate_org_id = [
item["orgId"]
for item in org_list
if item.get("abbrName") == org_name
]
else:
operate_org_id = org_list[0].get("orgId")
if not operate_org_id:
logger.error("门店ID为空")
return None
logger.info(f"获取门店ID成功: {operate_org_id}")
return operate_org_id
except Exception as e:
logger.error(f"获取门店ID时发生错误: {e}")
return None
def get_card_list(
cookies: Dict[str, str],
operate_org_id: str,
extract_func: Callable[[Dict], Optional[str]] = None
) -> List[str]:
"""
获取会员卡列表
从F6系统获取指定门店的会员卡列表,支持自定义提取逻辑。
Args:
cookies: 用户登录 F6 系统的 cookies 信息
operate_org_id: 门店ID
extract_func: 自定义提取函数,用于从会员卡数据中提取ID
如果不提供,默认提取 idCustomer 字段
Returns:
List[str]: 会员卡ID列表
注意:
- 默认每页100条数据,会自动分页获取所有数据
- 每页请求间隔0.2秒,避免请求过快
"""
card_list = []
try:
# 获取第一页,确定总页数
card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1"
card_res = requests.get(url=card_url, cookies=cookies)
total_card = int(card_res.json().get("data", {}).get("total", 0))
if total_card == 0:
logger.info("未找到会员卡数据")
return card_list
total_page = total_card // 100 + (total_card % 100 > 0)
logger.info(f"会员卡总数: {total_card}, 总页数: {total_page}")
# 定义默认提取函数(提取客户ID
if extract_func is None:
def default_extract(card_item: Dict) -> Optional[str]:
return card_item.get("idCustomer")
extract_func = default_extract
# 分页获取所有会员卡数据
for page in tqdm(range(1, total_page + 1), desc="查询会员卡"):
card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}"
f"&pageSize=100&pageNo={page}")
card_res = requests.get(url=card_url, cookies=cookies)
card_data_list = card_res.json().get("data", {}).get("data", [])
# 使用提取函数提取ID
for card_item in card_data_list:
extracted_id = extract_func(card_item)
if extracted_id is not None:
card_list.append(extracted_id)
time.sleep(0.2)
logger.info(f"获取会员卡列表成功,共 {len(card_list)}")
return card_list
except Exception as e:
logger.error(f"获取会员卡列表时发生错误: {e}")
return card_list
+178 -227
View File
@@ -1,12 +1,16 @@
"""
客户相关后台任务模块
包含修改客户信息等功能
本模块包含客户相关的后台任务,包括:
- 客户信息批量修改
这些任务在后台线程中执行,不会阻塞主请求。
"""
import logging
import requests
import pandas as pd
import time
import re
import os
from typing import Dict, Any, Optional
from app.tasks.common import update_jiandaoyun, approve_workflow
@@ -15,247 +19,194 @@ logger = logging.getLogger('app')
def modify_customer_info_background(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str):
"""
修改客户信息后台任务
修改客户信息后台任务
此函数用于后台任务,用于修改会员信息。
在后台线程中批量修改客户信息,从 Excel 文件中读取客户手机号和修改信息。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data (Dict[str, Any]): 前端请求发送过来的数据,包含文件信息和其他必要参数。
cookies (Dict[str, str]): 登录用户的Cookies
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
df: Excel 文件读取的内容,DataFrame 格式,第一列为客户手机号
save_path: Excel 文件保存的地址,执行完成后会删除此文件
Returns:
None
注意:
- 根据客户手机号匹配客户信息
- 执行完成后会自动删除上传的文件
- 执行结果会更新到简道云表单
"""
df = df.where(pd.notnull(df), None)
params = {
'pageSize': 100,
'pageNo': '1',
}
try:
# 替换 NaN 为 None,便于后续判断
df = df.where(pd.notnull(df), None)
res = requests.get(
'https://yunxiu.f6car.cn/member/customer/listForPermission',
params=params,
cookies=cookies,
)
logger.info("开始获取当前客户下所有客户信息")
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
# 分页获取全部客户
params = {'pageSize': 100, 'pageNo': '1'}
res = requests.get(
'https://yunxiu.f6car.cn/member/customer/listForPermission',
params=params,
cookies=cookies,
timeout=10
)
res.raise_for_status()
total = int(res.json().get("data", {}).get("total", 0))
total_pages = (total // params["pageSize"]) + (1 if total % params["pageSize"] > 0 else 0)
logger.info(f"总计 {total_pages} 页,共 {total} 个客户")
cell_phone = safe_get(customer_info, 'cellPhone')
all_customers = []
for page in range(1, total_pages + 1):
logger.debug(f"正在请求第 {page} 页...")
params["pageNo"] = page
retry_count = 0
max_retries = 5
while retry_count < max_retries:
try:
response = requests.get(
'https://yunxiu.f6car.cn/member/customer/listForPermission',
params=params,
cookies=cookies,
timeout=10
)
response.raise_for_status()
page_data = response.json().get("data", {}).get("data", [])
all_customers.extend(page_data)
break
except Exception as e:
retry_count += 1
logger.warning(f"请求第 {page} 页失败(第 {retry_count} 次重试): {e}")
time.sleep(3)
else:
logger.error(f"{page} 页请求失败超过最大重试次数,跳过")
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'),
# 获取专属运营顾问列表
json_data = {
'includeStopedEmployee': False,
'pageSize': 1000,
'filterNullUser': False,
'keyword': '',
'idOwnOrgList': [],
}
staff_resp = requests.post(
'https://yunxiu.f6car.cn/hive/employee/searchStaffInGroup',
cookies=cookies,
json=json_data,
timeout=10
)
staff_resp.raise_for_status()
staff_list = staff_resp.json().get("data", {}).get("list", [])
name_to_userid = {
emp['name']: emp['userId']
for emp in staff_list
if emp.get('userId') is not None
}
return request_data
# 在 df 中添加 userId 列
df['userId'] = df['专属运营顾问'].map(name_to_userid)
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("修改完成")
# 字段映射:Excel 列名 -> F6 字段名
FIELD_MAPPING = {
'客户姓名': 'name',
'客户类型': 'customerType',
'客户来源': 'customerSourceName',
'单位名称': 'companyName',
'客户备注': 'customerMemo',
'专属运营顾问': 'exclusiveConsultantName', # userId 单独处理
}
time.sleep(1)
def convert_to_request_data(original_data: dict, df_row: pd.Series) -> dict:
"""以原始客户数据为基础,仅覆盖 Excel 中非空字段"""
customer_info = original_data.get("data", {}).get("customerInfo", {}) or {}
request_data = dict(customer_info) # 浅拷贝,足够用
msg = update_jiandaoyun(data, f'修改完成')
# 覆盖指定字段
for excel_col, f6_field in FIELD_MAPPING.items():
value = df_row.get(excel_col)
if pd.notna(value):
request_data[f6_field] = str(value).strip() if isinstance(value, str) else value
if msg.get('msg'):
approve_workflow(data)
print('表单已自动提交至下一步')
# 处理专属顾问 ID
if pd.notna(df_row.get('userId')):
request_data['exclusiveConsultantId'] = df_row['userId']
# 确保必要字段
if 'idCustomer' in customer_info:
request_data['pkId'] = customer_info['idCustomer']
request_data['idCustomer'] = customer_info['idCustomer']
return request_data
# 执行批量更新
updated_count = 0
for customer in all_customers:
phone = customer.get("cellPhone")
if not phone:
continue
matched_rows = df[df['客户手机号'] == phone]
if matched_rows.empty:
continue
df_row = matched_rows.iloc[0]
cus_id = customer.get("idCustomer")
if not cus_id:
logger.warning(f"客户手机号 {phone} 缺少 idCustomer,跳过")
continue
try:
# 获取完整客户信息
detail_resp = requests.get(
f'https://yunxiu.f6car.cn/member/customer/{cus_id}',
cookies=cookies,
timeout=10
)
detail_resp.raise_for_status()
original_data = detail_resp.json()
# 构造更新数据
final_json_data = convert_to_request_data(original_data, df_row)
# 发送更新
update_resp = requests.post(
'https://yunxiu.f6car.cn/member/customer/modifyCustomer',
cookies=cookies,
json=final_json_data,
timeout=10
)
if update_resp.status_code == 200 and update_resp.json().get('success'):
logger.info(f"✅ 客户 {phone} 更新成功")
updated_count += 1
else:
logger.error(f"❌ 客户 {phone} 更新失败: {update_resp.text}")
time.sleep(1) # 避免触发限流
except Exception as e:
logger.exception(f"处理客户 {phone} 时发生异常: {e}")
# 更新简道云状态
msg = update_jiandaoyun(data, f'批量修改完成,共更新 {updated_count} 个客户')
if msg.get('msg'):
approve_workflow(data)
logger.info('✅ 表单已自动提交至下一步')
except Exception as e:
logger.exception("modify_customer_info_background 执行出错:")
# 即使出错也尝试通知简道云
try:
update_jiandaoyun(data, f'执行失败: {str(e)[:200]}')
except:
pass
finally:
# 清理临时文件
try:
if os.path.exists(save_path):
os.remove(save_path)
logger.info(f"临时文件已删除: {save_path}")
except Exception as e:
logger.warning(f"删除临时文件失败: {e}")
+253 -194
View File
@@ -1,217 +1,277 @@
"""
删除相关后台任务模块
包含删除历史维修记录、删除客户信息、删除客户车辆信息等功能
本模块包含删除相关的后台任务,包括:
- 删除历史维修记录
- 删除客户信息
- 删除客户车辆信息
这些任务在后台线程中执行,不会阻塞主请求。
执行完成后会更新简道云表单并自动提交工作流。
"""
import logging
import traceback
import requests
import time
from typing import Dict, Any, List
from typing import Dict, Any, List, Optional
from datetime import datetime
from tqdm import tqdm
from app.tasks.common import update_jiandaoyun, approve_workflow
from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id, get_card_list, \
execute_failure_handler
logger = logging.getLogger('app')
def delete_history_background(data: Dict[str, Any], cookies: Dict[str, str], org_id: str, org_name: str):
"""
删除历史维修数据后台运行函数
:param data: 包含表单id、数据id等的字典
:param cookies: 用户登录F6系统的cookies信息
:param org_id: 需要删除历史维修记录的门店id
:param org_name: 需要删除历史维修记录的门店名称
:return: None
删除历史维修记录后台任务
在后台线程中删除指定门店的历史维修记录。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
org_id: 需要删除历史维修记录的门店ID
org_name: 需要删除历史维修记录的门店名称
Returns:
None
"""
url = f'https://yunxiu.f6car.cn/maintain-dump/maintainHistory/?orgid={org_id}' # 删除url
res = requests.delete(url=url, cookies=cookies)
res_data = res.json()
try:
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')}")
url = f'https://yunxiu.f6car.cn/maintain-dump/maintainHistory/?orgid={org_id}' # 删除url
res = requests.delete(url=url, cookies=cookies)
res_data = res.json()
# 调用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)
if res.status_code == 200 and res_data.get('code') == 200:
results = f'{org_name} 历史维修记录已删除'
print(results)
logger.info(f"删除 {org_name} 历史维修记录成功")
else:
time.sleep(3)
results = f'删除 {org_name} 历史维修记录失败: {res_data.get("message")}'
data["failure_name"] = "删除历史维修记录后台任务"
data["failure_details"] = results
execute_failure_handler(data)
logger.error(f"删除 {org_name} 历史维修记录失败: {res_data.get('message')}")
logger.info(f"客户删除结果: 成功次数={success}, 失败次数={fail}")
# 调用api回写改掉 执行明细与执行状态
time.sleep(1)
msg = update_jiandaoyun(data, f'{results}')
msg = update_jiandaoyun(data, f'成功次数{success},失败次数{fail}')
if msg.get('msg'):
approve_workflow(data)
print('表单已自动提交至下一步')
except Exception as e:
logger.error(f"删除 {org_name} 历史维修记录失败: {e}")
logger.error(traceback.format_exc())
data["failure_name"] = "删除历史维修记录后台任务"
data["failure_details"] = traceback.format_exc()
execute_failure_handler(data)
def delete_customer_background(data: Dict[str, Any], cookies: Dict[str, str], total: int):
"""
删除客户信息后台任务
在后台线程中批量删除客户信息。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
json_data: 获取到的客户信息列表,包含要删除的客户信息
Returns:
None
注意:
- 8-20点之间每3.5秒删除一条数据,其余时间每1.5秒删除一条数据
- 执行结果会更新到简道云表单
"""
try:
logger.info("开始删除客户信息")
success = 0
fail = 0
json_data = []
total_page = total // 100 + (total % 100 > 0)
logger.info("正在加载客户信息")
for page in tqdm(range(1, total_page + 1)):
url = f"https://yunxiu.f6car.cn/member/customer/listForPermission?pageSize=100&pageNo={page}"
res = requests.get(url, cookies=cookies)
json_data.extend(res.json().get('data', {}).get('data', []))
# 获取门店ID
operate_org_id = get_operate_org_id(cookies)
if not operate_org_id:
msg = update_jiandaoyun(data, '删除失败: 未获取到门店信息')
if msg.get('msg'):
approve_workflow(data)
return
# 获取会员卡列表(提取客户ID
card_list_customers = get_card_list(cookies, operate_org_id)
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, timeout=10) # 客户信息删除
# 检查HTTP状态码
if res.status_code != 200:
fail += 1
error_msg = f"HTTP状态码错误: {res.status_code}"
logger.error(f"客户删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
print(f"删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
time.sleep(0.2)
continue
# 解析响应数据
try:
res_data = res.json()
except ValueError as json_error:
fail += 1
error_msg = f"响应不是有效的JSON格式: {json_error}, 响应内容: {res.text[:200]}"
logger.error(f"客户删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
print(f"删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
time.sleep(0.2)
continue
# 检查多种可能的成功标识
# 有些API返回 success 字段,有些返回 code=200,有些返回 data 字段
is_success = (
res_data.get('success') is True or
res_data.get('code') == 200 or
(res_data.get('code') is None and res_data.get('data') is not None)
)
if is_success:
success += 1
logger.info(f"客户删除成功: ID={id_customer}, 手机号={phone}")
else:
fail += 1
error_msg = res_data.get('message') or res_data.get('msg') or '未知错误'
error_detail = f"错误信息: {error_msg}, 完整响应: {res_data}"
logger.error(f"客户删除失败: ID={id_customer}, 手机号={phone}, {error_detail}")
print(f"删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
time.sleep(0.2)
except requests.exceptions.RequestException as e:
fail += 1
error_msg = f"网络请求异常: {str(e)}"
print(f"删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
logger.error(f"删除客户时发生网络错误: ID={id_customer}, 手机号={phone}, {error_msg}")
except Exception as e:
fail += 1
error_msg = f"未知错误: {str(e)}"
print(f"删除失败: ID={id_customer}, 手机号={phone}, {error_msg}")
logger.error(f"删除客户时发生错误: ID={id_customer}, 手机号={phone}, {error_msg}", exc_info=True)
now = datetime.now()
if 8 <= now.hour <= 20:
time.sleep(3.5)
else:
time.sleep(1.5)
logger.info(f"客户删除结果: 成功次数={success}, 失败次数={fail}")
msg = update_jiandaoyun(data, f'成功次数{success},失败次数{fail}')
if msg.get('msg'):
approve_workflow(data)
print('表单已自动提交至下一步')
except Exception as e:
logger.error(f"删除客户信息时发生错误: {str(e)}")
logger.error(traceback.format_exc())
data["failure_name"] = "删除客户信息后台任务"
data["failure_details"] = traceback.format_exc()
execute_failure_handler(data)
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
删除客户车辆信息后台任务
在后台线程中批量删除客户车辆信息
会检查车辆是否有会员卡或最近消费记录,有则跳过删除。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
url: 获取车辆列表的 API URL
cookies: 用户登录 F6 系统的 cookies 信息
header: HTTP 请求头字典,应包含账号登录的请求头
all_page: 总页数(字符串或整数),用于分页获取车辆列表
Returns:
None
注意:
- 8-20点之间每3.5秒删除一条数据,其余时间每1.5秒删除一条数据
- 有会员卡或最近消费记录的车辆会被跳过
- 执行结果会更新到简道云表单
"""
print(cookies)
success = 0
fail = 0
try:
# 确保 all_page 是一个整数
all_page = int(all_page)
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}")
# 获取门店ID
operate_org_id = get_operate_org_id(cookies)
if not operate_org_id:
msg = update_jiandaoyun(data, '删除失败: 未获取到门店信息')
if msg.get('msg'):
approve_workflow(data)
return
print(operate_org_id)
# 获取会员卡列表(提取车辆ID
# 注意:需要获取所有车辆的ID,所以不能直接使用 get_card_list
# 需要自定义提取逻辑,返回所有车辆的ID列表
card_list_cars = []
# 获取第一页,确定总页数
card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1"
card_res = requests.get(url=card_url, cookies=cookies)
card_cars_list = card_res.json().get("data").get("data")
total_card = int(card_res.json().get("data", {}).get("total", 0))
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)
if total_card > 0:
total_page = total_card // 100 + (total_card % 100 > 0)
for page in tqdm(range(1, total_page + 1), desc="查询会员卡"):
card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}"
f"&pageSize=100&pageNo={page}")
card_res = requests.get(url=card_url, cookies=cookies)
card_cars_list = card_res.json().get("data", {}).get("data", [])
for card_car in card_cars_list:
if card_car.get("cars") is None:
continue
for car in card_car.get("cars", []):
car_id = car.get("idCar")
if car_id:
card_list_cars.append(car_id)
time.sleep(0.2)
except Exception as e:
logger.error(f"获取会员卡列表时发生错误: {e}")
itemlist = []
# 使用 range() 创建一个可迭代的对象
@@ -289,16 +349,15 @@ def delete_car_background(data: Dict[str, Any], url: str, cookies: Dict[str, str
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() # 打印堆栈跟踪信息
msg = update_jiandaoyun(data, f'成功次数{success},失败次数{fail}')
if msg.get('msg'):
approve_workflow(data)
print('表单已自动提交至下一步')
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('表单已自动提交至下一步')
logger.error(f"删除客户车辆信息时发生错误: {str(e)}")
logger.error(traceback.format_exc())
data["failure_name"] = "删除客户车辆信息后台任务"
data["failure_details"] = traceback.format_exc()
execute_failure_handler(data)
+892
View File
@@ -0,0 +1,892 @@
"""
项目材料相关后台任务模块
本模块包含项目材料相关的后台任务,包括:
- 项目信息批量停用
- 材料信息批量修改
这些任务在后台线程中执行,不会阻塞主请求。
"""
import logging
import traceback
import requests
import time
from typing import Dict, Any
from tqdm import tqdm
from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id
import pandas as pd
import os
logger = logging.getLogger('app')
def batch_disable_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str,
option) -> None:
"""
项目批量启停后台任务
在后台线程中批量停用项目,从 Excel 文件中读取项目编码。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
df: Excel 文件读取的内容,DataFrame 格式,第一列为项目编码
save_path: Excel 文件保存的地址,执行完成后会删除此文件
option: 批量启用、批量停用
Returns:
None
注意:
- 无效的项目(None、空字符串)会被跳过
- 执行完成后会自动删除上传的文件
- 执行结果会更新到简道云表单
"""
logger.info(f"开始执行项目批量启停任务,操作类型: {option}, 文件路径: {save_path}, 数据行数: {len(df)}")
if option == "批量启用":
type_ = 0 # 1 停用,0启用
ob_type = 1
else:
type_ = 1
ob_type = 0
logger.info(f"操作类型设置完成: type_={type_}, ob_type={ob_type}")
df = df.where(pd.notnull(df), None)
# 获取门店id
logger.info("正在获取门店ID...")
try:
org_id = get_operate_org_id(cookies)
logger.info(f"门店ID获取成功: {org_id}")
except Exception as e:
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 获取项目信息
logger.info("开始获取项目列表...")
json_data = {
'param': '',
'name': '',
'customCode': '',
'currentPage': 1,
'pageSize': 100,
'isDel': ob_type,
'customInvoiceCategory': 0,
'idOwnOrg': org_id,
}
try:
response = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
cookies=cookies,
json=json_data,
)
time.sleep(1)
response.raise_for_status()
all_project_list = []
total_pages = response.json().get("data", {}).get("totalPages", 0)
logger.info(f"获取项目列表响应: {response.json()}")
try:
total_pages = int(total_pages)
except (ValueError, TypeError):
logger.error(f"无法解析总页数: {total_pages}, 类型: {type(total_pages)}")
total_pages = 0
logger.info(f"项目列表总页数: {total_pages}")
for page in tqdm(range(1, total_pages + 1)):
json_data['currentPage'] = page
try:
response = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
cookies=cookies,
json=json_data,
)
time.sleep(1)
response.raise_for_status()
project_list = response.json().get("data", {}).get("records", [])
all_project_list.extend(project_list)
logger.debug(f"{page}页获取到{len(project_list)}条项目")
except requests.exceptions.RequestException as e:
logger.error(f"获取第{page}页项目列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
raise
logger.info(f"项目列表获取完成,总计: {len(all_project_list)}条项目")
except requests.exceptions.RequestException as e:
logger.error(f"获取项目列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 遍历获取到的项目信息停用文件中的项目
code_list = df.iloc[:, 0].dropna().astype(str).tolist()
logger.info(f"Excel文件中待处理的项目编码数量: {len(code_list)}")
results = []
consecutive_failures = 0 # 连续失败计数器
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
success_count = 0
failure_count = 0
logger.info("开始处理项目启停操作...")
for item in tqdm(all_project_list):
custom_code = item.get("customCode")
if not code_list or not custom_code or str(custom_code) not in code_list:
continue
logger.debug(f"正在处理项目编码: {custom_code}")
info_id = item.get("infoId")
pk_id = item.get("pkId")
if not info_id or not pk_id:
logger.warning(f"项目编码 {custom_code} 缺少必要字段: infoId={info_id}, pkId={pk_id}")
results.append({'项目编码': custom_code, '状态': '缺少必要字段(infoId或pkId)'})
failure_count += 1
consecutive_failures += 1
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
json_data = {
"orgIdList": [
org_id,
],
"isDel": type_, # 1 停用 0启用
"infoId": info_id,
"pkId": pk_id,
"type": 1,
"idOwnOrg": org_id
}
try:
logger.debug(f"发送启停请求,项目编码: {custom_code}, 操作类型: {type_}")
response = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/editAttributeByType',
cookies=cookies,
json=json_data,
)
time.sleep(2)
response.raise_for_status() # 抛出HTTP错误
# 检查业务响应码
resp_data = response.json()
if resp_data.get("code") == 200:
results.append({'项目编码': custom_code, '状态': '停用/启用成功'})
success_count += 1
consecutive_failures = 0 # 成功时重置计数器
logger.info(f"项目编码 {custom_code} 启停操作成功")
else:
msg = resp_data.get("message", "未知错误")
results.append({'项目编码': custom_code, '状态': f'停用/启用失败: {msg}'})
failure_count += 1
consecutive_failures += 1
logger.error(f"项目编码 {custom_code} 启停操作失败: {msg}, 响应数据: {resp_data}")
except requests.exceptions.RequestException as e:
error_msg = str(e)
results.append({'项目编码': custom_code, '状态': f'停用/启用失败: {error_msg}'})
failure_count += 1
consecutive_failures += 1
logger.error(f"项目编码 {custom_code} 启停操作请求异常: {error_msg}, 堆栈信息: {traceback.format_exc()}")
# 检查连续失败次数
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
logger.info(f"项目启停处理完成,成功: {success_count}条, 失败: {failure_count}条, 总计: {len(results)}")
print({'msg': '已执行', 'msg_details': f'{results}'})
logger.info(f"停用/启用结果: {results}")
# 删除文件
logger.info(f"准备删除文件: {save_path}")
try:
os.remove(save_path)
logger.info(f"文件删除成功: {save_path}")
print(f'{save_path}已删除')
except Exception as e:
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
# 调用api回写改掉 执行明细与执行状态
logger.info("开始回写简道云表单...")
try:
msg = update_jiandaoyun(data, f'{results}')
logger.info(f"简道云表单回写结果: {msg}")
if msg.get('msg'):
logger.info("开始自动提交工作流...")
approve_workflow(data)
logger.info("工作流提交成功")
print('表单已自动提交至下一步')
else:
logger.warning(f"简道云表单回写失败: {msg}")
except Exception as e:
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
logger.info(f"项目批量启停任务执行完成,操作类型: {option}")
def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str) -> None:
"""
材料批量修改后台任务
在后台线程中批量修改材料,从 Excel 文件中读取材料编码。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
df: Excel 文件读取的内容,DataFrame 格式,列顺序为:
[原材料编码, 新材料编码, 品牌, 名称, 规格]
save_path: Excel 文件保存的地址,执行完成后会删除此文件
注意:
- 无效的材料编码(None、空字符串)会被跳过
- Excel 中某字段为空(NaN 或空字符串)时,保留原材料中的对应字段
- 执行完成后会自动删除上传的文件
- 执行结果会更新到简道云表单
"""
logger.info(f"开始执行材料批量修改任务,文件: {save_path},行数: {len(df)}")
def safe_str(val):
"""将值转为字符串,NaN 返回空字符串"""
if pd.isna(val):
return ""
return str(val).strip()
def should_update(new_val):
"""判断是否应该用 new_val 更新:非 NaN 且非空字符串"""
return not (pd.isna(new_val) or (isinstance(new_val, str) and new_val.strip() == ""))
# 获取门店id
logger.info("正在获取门店ID...")
try:
org_id = get_operate_org_id(cookies)
logger.info(f"门店ID获取成功: {org_id}")
except Exception as e:
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 第一步:获取所有材料列表(分页)
json_data = {
'keyWord': '',
'idOwnOrg': org_id,
'currentPage': 1,
'pageSize': 100,
'name': '',
'brand': '',
'supplierCode': '',
'customCode': '',
'categoryName': '',
'categoryId': '',
'labelId': '',
'labelName': '',
'spec': '',
'applyModel': '',
'sellPurchaseStatuses': [2, 3, 4, 5],
'customInvoiceCategory': 0,
'getThirdPlatformCode': 0,
}
try:
response = requests.post(
'https://ids-goods.f6car.com/f6-ids-goods/part/getExactPartStockInfo',
cookies=cookies,
json=json_data,
)
response.raise_for_status()
total_pages = response.json().get("data", {}).get("totalPages", 0)
logger.info(f"材料列表页数: {total_pages}")
all_materials_list = []
total_pages = int(total_pages)
for page in tqdm(range(1, total_pages + 1), desc="获取材料列表"):
json_data['currentPage'] = page
try:
resp = requests.post(
'https://ids-goods.f6car.com/f6-ids-goods/part/getExactPartStockInfo',
cookies=cookies,
json=json_data,
)
time.sleep(1)
resp.raise_for_status()
records = resp.json().get("data", {}).get("records", [])
all_materials_list.extend(records)
except requests.exceptions.RequestException as e:
logger.error(f"获取第{page}页材料列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
raise
logger.info(f"材料列表获取完成,总计: {len(all_materials_list)}")
except requests.exceptions.RequestException as e:
logger.error(f"获取材料列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 第二步:构建 update_map(只存原始值,不预处理)
update_map = {}
skipped_count = 0
for _, row in df.iterrows():
orig_code = row.iloc[0] # 原材料编码
if pd.isna(orig_code) or str(orig_code).strip() == "":
skipped_count += 1
continue
orig_code = str(orig_code).strip()
update_map[orig_code] = {
"new_customCode": row.iloc[1],
"new_brand": row.iloc[2],
"new_name": row.iloc[3],
"new_spec": row.iloc[4],
}
logger.info(f"待更新材料数: {len(update_map)},跳过空编码行: {skipped_count}")
# 第三步:遍历材料,按需更新
results = []
consecutive_failures = 0 # 连续失败计数器
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
success_count = 0
failure_count = 0
skip_count = 0
for item in tqdm(all_materials_list, desc="处理材料更新"):
custom_code = item.get("customCode")
if not custom_code or str(custom_code) not in update_map:
continue
part_id = item.get("partId")
if not part_id:
error_msg = '缺少 partId'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.warning(f"材料编码 {custom_code} 跳过/失败: {error_msg}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
try:
# 获取材料明细
params = {
'partId': part_id,
'customInvoiceCategory': '0',
'getThirdPlatformCode': '0',
}
materials_response = requests.get(
'https://ids-goods.f6car.com/f6-ids-goods/part/getPartInfo',
params=params,
cookies=cookies,
)
time.sleep(1)
if materials_response.status_code != 200:
error_msg = f'获取明细失败: {materials_response.status_code}'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"材料编码 {custom_code} {error_msg}, 响应内容: {materials_response.text[:200]}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
detail = materials_response.json().get("data")
if not detail:
error_msg = '明细为空'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"材料编码 {custom_code} {error_msg}, 响应JSON: {materials_response.json()}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
updates = update_map[str(custom_code)]
# 安全更新字段:仅当 Excel 提供有效值时才覆盖
if should_update(updates["new_customCode"]):
detail["customCode"] = safe_str(updates["new_customCode"])
if should_update(updates["new_brand"]):
detail["brand"] = safe_str(updates["new_brand"])
if should_update(updates["new_name"]):
name_val = safe_str(updates["new_name"])
detail["name"] = name_val
detail["showName"] = name_val # 同步 showName
if should_update(updates["new_spec"]):
detail["spec"] = safe_str(updates["new_spec"])
# 修复价格格式和数组(必须!)
for f in ["purchasePrice", "sellPrice"]:
val = detail.get(f)
if isinstance(val, (int, float)):
detail[f] = f"{val:.2f}"
if detail.get("partBarCodeVos") is None:
detail["partBarCodeVos"] = []
# 提交更新
update_resp = requests.post(
'https://ids-goods.f6car.com/f6-ids-goods/part/updateAreaPartBasicInfo',
cookies=cookies,
json=detail
)
time.sleep(2)
if update_resp.status_code == 200 and update_resp.json().get("code") == 200:
results.append({'材料编码': custom_code, '状态': '修改成功'})
success_count += 1
consecutive_failures = 0 # 成功时重置计数器
else:
msg = update_resp.json().get("message", "未知错误")
error_msg = f'修改失败: {msg}'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"材料编码 {custom_code} {error_msg}, 响应数据: {update_resp.json()}")
except requests.exceptions.RequestException as e:
error_msg = f'请求异常: {str(e)}'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"材料编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
except Exception as e:
error_msg = f'内部错误: {str(e)}'
results.append({'材料编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"材料编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
# 检查连续失败次数
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
# 统计汇总(总行数 / 待处理 / 成功 / 失败 / 跳过)
total_rows = len(df)
to_process = len(update_map)
# results 里可能包含 “缺少 partId” 等失败信息;这里跳过数按空编码行计数即可
skip_count = skipped_count
summary = {
"总行数": total_rows,
"待处理": to_process,
"成功": success_count,
"失败": failure_count,
"跳过": skip_count,
}
results.insert(0, {"汇总": summary})
logger.info(f"材料修改汇总: {summary}")
# 第四步:清理与回写
print({'msg': '已执行', 'msg_details': results})
# 结果回写包含汇总 + 明细
logger.info("材料批量修改完成,开始回写结果")
# 删除文件
logger.info(f"准备删除文件: {save_path}")
try:
os.remove(save_path)
logger.info(f"文件删除成功: {save_path}")
print(f'{save_path} 已删除')
except Exception as e:
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
# 回写简道云
logger.info("开始回写简道云表单...")
try:
msg = update_jiandaoyun(data, str(results))
logger.info(f"简道云表单回写结果: {msg}")
if msg.get('msg'):
logger.info("开始自动提交工作流...")
approve_workflow(data)
logger.info("工作流提交成功")
print('表单已自动提交至下一步')
else:
logger.warning(f"简道云表单回写失败: {msg}")
except Exception as e:
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
logger.info("材料批量修改任务执行完成")
def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str) -> None:
"""
项目批量修改后台任务
在后台线程中批量修改项目,从 Excel 文件中读取项目编码。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息
df: Excel 文件读取的内容,DataFrame 格式,列顺序为:
[0:原项目编码, 1:新项目编码, 2:新项目名称, 3:业务分类, 4:销项税率, 5:项目说明]
save_path: Excel 文件保存的地址,执行完成后会删除此文件
注意:
- 无效的项目编码(None、空字符串)会被跳过
- Excel 中某字段为空(NaN 或空字符串)时,保留原始项目中的对应字段
- 如果新项目名称与系统已有项目名称重复,则跳过处理
- 如果Excel中多个行的新项目名称重复,只执行第一条数据,后续重复的会被跳过
- 执行完成后会自动删除上传的文件
- 执行结果会更新到简道云表单
"""
logger.info(f"开始执行项目批量修改任务,文件: {save_path},行数: {len(df)}")
def safe_str(val):
"""将值转为字符串,NaN 返回空字符串"""
if pd.isna(val):
return ""
return str(val).strip()
def safe_float(val):
"""安全转换为 float,失败返回 None"""
if pd.isna(val):
return None
try:
return float(val)
except (ValueError, TypeError):
return None
def should_update(new_val):
"""判断是否应该用 new_val 更新:非 NaN 且非空字符串"""
return not (pd.isna(new_val) or (isinstance(new_val, str) and new_val.strip() == ""))
def safe_iloc(row, idx, default=None):
"""安全按索引取行值,列数不足时返回 default,避免 IndexError"""
try:
if idx < len(row):
return row.iloc[idx]
except (IndexError, KeyError):
pass
return default
# 获取门店id
logger.info("获取门店ID...")
try:
org_id = get_operate_org_id(cookies)
logger.info(f"门店ID获取成功: {org_id}")
except Exception as e:
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 获取服务分类(用于映射业务分类名称 → pkId)
logger.info("获取服务分类列表...")
try:
init_add_resp = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/initAdd',
cookies=cookies,
data={'idOwnOrg': org_id}
)
time.sleep(1)
init_add_resp.raise_for_status()
service_category_list = init_add_resp.json().get("data", {}).get("serviceCategory", [])
category_name_to_pk = {item["name"]: item["pkId"] for item in service_category_list}
logger.info(f"服务分类列表获取成功,共{len(category_name_to_pk)}个分类")
except requests.exceptions.RequestException as e:
logger.error(f"获取服务分类列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 第一步:获取所有项目列表(分页)
logger.info("获取项目列表...")
json_data = {
'param': '',
'name': '',
'customCode': '',
'currentPage': 1,
'pageSize': 100,
'isDel': 0, # 只查启用的
'customInvoiceCategory': 0,
'idOwnOrg': org_id,
}
try:
response = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
cookies=cookies,
json=json_data,
)
response.raise_for_status()
total_pages = response.json().get("data", {}).get("totalPages", 0)
logger.info(f"项目列表总页数: {total_pages}")
all_project_list = []
total_pages = int(total_pages)
for page in tqdm(range(1, total_pages + 1), desc="获取项目列表"):
json_data['currentPage'] = page
try:
resp = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
cookies=cookies,
json=json_data,
)
time.sleep(1)
resp.raise_for_status()
records = resp.json().get("data", {}).get("records", [])
all_project_list.extend(records)
except requests.exceptions.RequestException as e:
logger.error(f"获取第{page}页项目列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
raise
logger.info(f"项目列表获取完成,总计: {len(all_project_list)}条项目")
except requests.exceptions.RequestException as e:
logger.error(f"获取项目列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
raise
# 构建系统已有项目名称集合(用于检查重复)
existing_project_names = set()
for project in all_project_list:
project_name = project.get("name")
if project_name:
existing_project_names.add(str(project_name).strip())
logger.info(f"系统已有项目名称数: {len(existing_project_names)}")
# 第二步:构建 update_map
logger.info("构建更新映射表...")
update_map = {}
skipped_count = 0
category_not_found_count = 0
duplicate_name_in_df_count = 0 # DataFrame中重复的新项目名称数量
duplicate_name_in_system_count = 0 # 与系统已有项目名称重复的数量
seen_new_names = {} # 用于跟踪DataFrame中已出现的新项目名称,key为新名称,value为第一次出现的原项目编码
results = [] # 提前创建results,用于记录跳过的信息
for idx, row in df.iterrows():
orig_code = safe_iloc(row, 0)
if pd.isna(orig_code) or str(orig_code).strip() == "":
skipped_count += 1
results.append({'项目编码': '', '状态': '跳过: 项目编码为空'})
continue
orig_code = str(orig_code).strip()
# 检查新项目名称(第2列,索引为2
new_name = safe_iloc(row, 2)
if should_update(new_name):
new_name_clean = safe_str(new_name)
# 检查DataFrame中是否有重复的新项目名称
if new_name_clean in seen_new_names:
duplicate_name_in_df_count += 1
logger.warning(f"DataFrame中项目名称重复,跳过: 原项目编码={orig_code}, 新项目名称={new_name_clean}, 首次出现在原项目编码={seen_new_names[new_name_clean]}")
results.append({'项目编码': orig_code, '状态': f'跳过: DataFrame中项目名称重复(首次出现在原项目编码={seen_new_names[new_name_clean]})'})
continue
# 检查新项目名称是否与系统已有项目名称重复
if new_name_clean in existing_project_names:
duplicate_name_in_system_count += 1
logger.warning(f"新项目名称与系统已有项目名称重复,跳过: 原项目编码={orig_code}, 新项目名称={new_name_clean}")
results.append({'项目编码': orig_code, '状态': f'跳过: 新项目名称与系统已有项目名称重复({new_name_clean})'})
continue
# 记录这个新名称
seen_new_names[new_name_clean] = orig_code
# 业务分类映射
category_name = safe_iloc(row, 3)
category_pk = None
category_not_found = False
if should_update(category_name):
cat_name_clean = safe_str(category_name)
category_pk = category_name_to_pk.get(cat_name_clean)
if category_pk is None:
logger.warning(f"业务分类 '{cat_name_clean}' 未在系统中找到,项目编码: {orig_code}")
category_not_found = True
category_not_found_count += 1
# 列 6/7/8(车辆分类、工时单价、工时)不再传入,固定为 None,不更新这些字段
car_category_name = None
price = None
work_hour = None
amount = None
update_map[orig_code] = {
"new_customCode": safe_iloc(row, 1), # 新项目编码
"new_name": safe_iloc(row, 2), # 新项目名称
"new_serviceCategoryId": category_pk,
"new_taxRate": safe_iloc(row, 4),
"new_memo": safe_iloc(row, 5),
"new_carCategoryName": car_category_name,
"new_price": price,
"new_workHour": work_hour,
"new_amount": amount,
}
logger.info(f"映射表完成: 待处理={len(update_map)}, 跳过空编码={skipped_count}, 分类未找到={category_not_found_count}, DF重复名={duplicate_name_in_df_count}, 系统重名={duplicate_name_in_system_count}")
# 第三步:遍历项目,按需更新
logger.info("开始处理项目更新...")
consecutive_failures = 0 # 连续失败计数器
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
success_count = 0
failure_count = 0
for item in tqdm(all_project_list, desc="处理项目更新"):
custom_code = item.get("customCode")
if not custom_code or str(custom_code) not in update_map:
continue
service_id = item.get("pkId") # 项目主键
if not service_id:
error_msg = '缺少 pkId'
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.warning(f"项目编码 {custom_code} {error_msg}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
try:
# 获取项目明细
params = {
'serviceId': service_id,
'customInvoiceCategory': '0',
}
detail_resp = requests.get(
'https://ids-goods.f6car.cn/f6-ids-goods/service/viewService',
params=params,
cookies=cookies,
)
time.sleep(1)
if detail_resp.status_code != 200:
error_msg = f'获取明细失败: {detail_resp.status_code}'
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"项目编码 {custom_code} {error_msg}, 响应内容: {detail_resp.text[:200]}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
detail = detail_resp.json().get("data")
if not detail:
error_msg = '明细为空'
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"项目编码 {custom_code} {error_msg}, 响应JSON: {detail_resp.json()}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
updates = update_map[str(custom_code)]
# === 安全更新字段(仅非空时覆盖)===
if should_update(updates["new_customCode"]):
detail["customCode"] = safe_str(updates["new_customCode"])
if should_update(updates["new_name"]):
name_val = safe_str(updates["new_name"])
# 再次检查新项目名称是否与系统已有项目名称重复(防止在构建update_map后系统状态发生变化)
if name_val in existing_project_names:
error_msg = f'跳过: 新项目名称与系统已有项目名称重复({name_val})'
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.warning(f"项目编码 {custom_code} {error_msg}")
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
continue
detail["name"] = name_val
if "showName" in detail:
detail["showName"] = name_val
if updates["new_serviceCategoryId"] is not None:
detail["serviceCategoryId"] = updates["new_serviceCategoryId"]
if should_update(updates["new_taxRate"]):
detail["taxRate"] = safe_str(updates["new_taxRate"])
if should_update(updates["new_memo"]):
detail["memo"] = safe_str(updates["new_memo"])
if should_update(updates["new_carCategoryName"]):
detail["carCategoryName"] = safe_str(updates["new_carCategoryName"])
if updates["new_price"] is not None:
detail["price"] = f"{updates['new_price']:.2f}"
if updates["new_workHour"] is not None:
detail["workHour"] = f"{updates['new_workHour']:.2f}"
if updates["new_amount"] is not None:
detail["amount"] = f"{updates['new_amount']:.2f}"
# === 提交更新 ===
update_resp = requests.post(
'https://ids-goods.f6car.cn/f6-ids-goods/service/editService',
cookies=cookies,
json=detail
)
time.sleep(2)
if update_resp.status_code == 200 and update_resp.json().get("code") == 200:
results.append({'项目编码': custom_code, '状态': '修改成功'})
success_count += 1
consecutive_failures = 0 # 成功时重置计数器
else:
msg = update_resp.json().get("message", "未知错误")
error_msg = f'修改失败: {msg}'
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
logger.error(f"项目编码 {custom_code} {error_msg}, 响应数据: {update_resp.json()}")
except requests.exceptions.RequestException as e:
error_msg = f"请求异常: {str(e)}"
logger.error(f"项目编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
except Exception as e:
error_msg = f"异常: {str(e)}"
logger.error(f"项目编码 {custom_code} 更新出错: {traceback.format_exc()}")
results.append({'项目编码': custom_code, '状态': error_msg})
failure_count += 1
consecutive_failures += 1
# 检查连续失败次数
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
break
# 汇总插入到结果顶部,便于简道云直接看到统计
total_rows = len(df)
to_process = len(update_map)
skip_count = skipped_count + duplicate_name_in_df_count + duplicate_name_in_system_count
summary = {
"总行数": total_rows,
"待处理": to_process,
"成功": success_count,
"失败": failure_count,
"跳过": skip_count,
}
results.insert(0, {"汇总": summary})
logger.info(f"项目修改汇总: {summary}")
# 第四步:清理与回写
print({'msg': '已执行', 'msg_details': results})
logger.info("项目批量修改完成,开始回写结果")
# 删除文件
logger.info(f"准备删除文件: {save_path}")
try:
os.remove(save_path)
logger.info(f"文件删除成功: {save_path}")
print(f'{save_path} 已删除')
except Exception as e:
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
# 回写简道云
logger.info("开始回写简道云表单...")
try:
msg = update_jiandaoyun(data, str(results))
logger.info(f"简道云表单回写结果: {msg}")
if msg.get('msg'):
logger.info("开始自动提交工作流...")
approve_workflow(data)
logger.info("工作流提交成功")
print('表单已自动提交至下一步')
else:
logger.warning(f"简道云表单回写失败: {msg}")
except Exception as e:
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
logger.info("项目批量修改任务执行完成")
+107
View File
@@ -1,3 +1,14 @@
"""
应用工具模块
本模块提供应用级的工具类包括
- 日志记录器配置支持日志轮转
- 后台任务调度器
- 任务队列管理
- 请求头解码工具
这些工具在整个应用中被广泛使用
"""
import logging
import os
from logging.handlers import RotatingFileHandler
@@ -8,7 +19,27 @@ from urllib.parse import unquote
class AppTools:
"""
应用级工具集合类
提供应用级别的工具功能包括
- 初始化轮转日志记录器
- 初始化后台调度器进程退出时自动关闭
- 维护一个简单的任务队列与后台处理线程
属性:
config: 配置对象
task_queue: 任务队列
logger: 日志记录器
scheduler: 后台调度器
"""
def __init__(self, config):
"""
初始化应用工具
Args:
config: 配置对象包含日志目录等配置信息
"""
self.config = config
self.task_queue = Queue()
self.logger = self._setup_logger()
@@ -16,6 +47,19 @@ class AppTools:
self._start_task_thread()
def _setup_logger(self):
"""
配置带轮转的文件日志记录器
创建支持日志轮转的文件日志记录器避免重复添加相同文件处理器
Returns:
logging.Logger: 配置好的日志记录器
注意:
- 日志文件最大 5MB
- 保留 5 个备份文件
- 使用 UTF-8 编码
"""
log_dir = self.config.LOGS_DIRECTORY
if not os.path.exists(log_dir):
os.makedirs(log_dir)
@@ -25,6 +69,7 @@ class AppTools:
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
# 若未绑定目标日志文件的 RotatingFileHandler,则创建并绑定
if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(log_file)
for h in logger.handlers):
file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024 * 5, backupCount=5, encoding='utf-8')
@@ -36,19 +81,43 @@ class AppTools:
return logger
def _setup_scheduler(self):
"""
初始化后台调度器
创建后台调度器并在进程退出时优雅关闭防止资源泄漏
Returns:
BackgroundScheduler: 后台调度器实例
注意:
调度器会在进程退出时自动关闭
"""
scheduler = BackgroundScheduler()
import atexit
atexit.register(lambda: scheduler.shutdown(wait=False))
return scheduler
def _start_task_thread(self):
"""
启动后台任务处理线程
启动一个守护线程来处理任务队列中的任务守护模式运行随主线程退出
"""
task_thread = threading.Thread(target=self.process_tasks, daemon=True)
task_thread.start()
def process_tasks(self):
"""
后台消费队列中的任务
任务结构为 {'handler': callable, 'data': any, 'response': Queue}
- 正常执行时将 handler(data) 的结果放入 response 队列
- 发生异常时记录错误并将失败信息放入 response 队列
- 每次任务完成后调用 task_done()
"""
while True:
task = self.task_queue.get()
if task is None:
# 外部以 None 作为结束信号
self.logger.error("任务处理线程已终止")
break
try:
@@ -62,6 +131,18 @@ class AppTools:
self.logger.info("任务处理完成")
def enqueue_task(self, handler, data):
"""
将任务入队
将任务放入任务队列并返回一个响应队列调用方可以从响应队列中获取执行结果
Args:
handler: 处理函数执行具体业务逻辑
data: 传递给处理函数的数据
Returns:
Queue: 响应队列用于获取任务执行结果
"""
response_queue = Queue()
self.task_queue.put({
'handler': handler,
@@ -72,13 +153,39 @@ class AppTools:
@staticmethod
def decode_headers(headers):
"""
解码请求头
对请求头字典进行 URL 解码UTF-8返回解码后的副本
主要用于处理包含中文字符的请求头
Args:
headers: 请求头字典
Returns:
dict: 解码后的请求头字典
"""
return {key: unquote(value, encoding='utf-8') for key, value in headers.items()}
# 全局日志记录器变量
# 用于存储全局日志记录器实例,避免重复初始化
logger = None
def setup_global_logger(config):
"""
设置全局日志记录器
懒加载并返回全局 logger若未初始化则构建 AppTools 并复用其中的 logger
避免重复初始化日志处理器
Args:
config: 配置对象
Returns:
logging.Logger: 全局日志记录器实例
"""
global logger
if logger is None:
app_tools = AppTools(config)
+452
View File
@@ -0,0 +1,452 @@
# 添加新任务类指南
本文档详细说明如何在项目中添加一个新的任务类,包含立即响应和后台执行功能。
## 目录
1. [架构概述](#架构概述)
2. [添加步骤](#添加步骤)
3. [代码示例](#代码示例)
4. [文件结构说明](#文件结构说明)
5. [注意事项](#注意事项)
---
## 架构概述
项目采用**立即响应 + 后台执行**的架构模式:
- **立即响应函数**:位于 `app/module/f6_plugin_handlers.py`,负责快速响应请求,立即返回"正在执行"消息
- **后台执行函数**:位于 `app/tasks/` 目录下,负责在后台线程中执行实际任务
### 工作流程
```
客户端请求
立即响应函数(F6PluginHandlers
启动后台线程
后台执行函数(tasks/*.py
更新简道云表单
自动提交工作流
```
---
## 添加步骤
### 步骤 1: 创建后台任务文件
`app/tasks/` 目录下创建新的任务文件,例如 `app/tasks/your_task.py`
```python
"""
你的任务相关后台任务模块
本模块包含你的任务相关的后台任务,包括:
- 任务描述1
- 任务描述2
这些任务在后台线程中执行,不会阻塞主请求。
执行完成后会更新简道云表单并自动提交工作流。
"""
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 your_task_background(data: Dict[str, Any], cookies: Dict[str, str] = None,
df: pd.DataFrame = None, save_path: str = None):
"""
你的任务后台执行函数
在后台线程中执行你的任务。
执行完成后会更新简道云表单并自动提交工作流。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
cookies: 用户登录 F6 系统的 cookies 信息(可选)
df: Excel 文件读取的内容,DataFrame 格式(可选)
save_path: Excel 文件保存的地址,执行完成后会删除此文件(可选)
Returns:
None
注意:
- 执行完成后会自动删除上传的文件(如果提供了save_path)
- 执行结果会更新到简道云表单
"""
try:
# TODO: 在这里实现具体的任务逻辑
results = []
# 示例:处理数据
if df is not None:
df = df.where(pd.notnull(df), None)
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理数据"):
# 实现具体的数据处理逻辑
result_item = {
'行号': index + 1,
'状态': '处理成功'
}
results.append(result_item)
else:
# 如果没有DataFrame,执行其他任务
results.append({'状态': '任务执行成功'})
# 删除文件(如果提供了save_path)
if save_path and os.path.exists(save_path):
os.remove(save_path)
logger.info(f'{save_path}已删除')
# 格式化结果
results_str = f'{results}' if results else '任务执行完成'
logger.info(f"任务执行结果: {results_str}")
# 调用api回写改掉 执行明细与执行状态
msg = update_jiandaoyun(data, results_str)
if msg.get('msg'):
approve_workflow(data)
logger.info('表单已自动提交至下一步')
except Exception as e:
error_msg = f'任务执行失败: {str(e)}'
logger.error(error_msg, exc_info=True)
msg = update_jiandaoyun(data, error_msg)
if msg.get('msg'):
approve_workflow(data)
```
### 步骤 2: 在任务导出文件中注册
`app/tasks/__init__.py` 中添加导入和导出:
```python
# 你的任务
from app.tasks.your_task import your_task_background
__all__ = [
# ... 其他任务
# 你的任务
'your_task_background',
]
```
### 步骤 3: 添加立即响应函数
`app/module/f6_plugin_handlers.py` 中添加立即响应函数:
```python
from app.tasks.your_task import your_task_background
class F6PluginHandlers:
# ... 其他方法
@staticmethod
def your_task(data: Dict[str, Any]) -> Dict[str, str]:
"""
你的任务
从简道云获取任务请求,读取 Excel 文件(如果需要),并在后台线程中执行任务。
立即返回"正在执行"的提示,实际执行在后台线程中完成。
Args:
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
Returns:
Dict[str, str]: 包含执行状态的字典
"""
entry_data = api_instance.entry_data_get(data=data)
print('执行 你的任务')
# 获取必要的参数(根据实际需求调整)
username = entry_data['data'].get('账号')
password = entry_data['data'].get('密码')
company_name = entry_data['data'].get('公司名称')
save_path = entry_data['data'].get('文件保存地址')
# 如果需要登录F6系统
cookies = None
if username and password and company_name:
login_response = F6Module.login_in(username, password, company_name)
if login_response is None:
return {'msg': '登录失败', 'msg_details': '无法登录F6系统'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
# 如果需要读取Excel文件
df = None
if save_path:
try:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
except Exception as e:
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
# 启动后台线程执行任务
try:
thread = threading.Thread(target=your_task_background,
args=(data, cookies, df, save_path))
thread.start()
except Exception as e:
print(f'创建线程失败: {str(e)}')
return {'msg': '任务启动失败', 'msg_details': f'无法启动后台任务: {str(e)}'}
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
```
### 步骤 4: 注册操作到模块注册表
`main.py``lifespan` 函数中注册操作:
```python
core_manager.register_action('your_task', f6_plugin_handlers.your_task, 'f6_plugin_module',
description='你的任务描述')
```
---
## 代码示例
### 完整示例:BI任务
以下是一个完整的示例,展示如何添加BI任务类:
#### 1. 后台任务文件 (`app/tasks/bi_tasks.py`)
```python
"""
BI相关后台任务模块
"""
import logging
import os
import pandas as pd
from typing import Dict, Any
from tqdm import tqdm
from app.tasks.common import update_jiandaoyun, approve_workflow
logger = logging.getLogger('app')
def bi_task_background(data: Dict[str, Any], cookies: Dict[str, str] = None,
df: pd.DataFrame = None, save_path: str = None):
"""BI任务后台执行函数"""
try:
results = []
if df is not None:
# 处理Excel数据
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理BI数据"):
results.append({'行号': index + 1, '状态': '处理成功'})
else:
results.append({'状态': 'BI任务执行成功'})
if save_path and os.path.exists(save_path):
os.remove(save_path)
msg = update_jiandaoyun(data, f'{results}')
if msg.get('msg'):
approve_workflow(data)
except Exception as e:
error_msg = f'BI任务执行失败: {str(e)}'
logger.error(error_msg, exc_info=True)
update_jiandaoyun(data, error_msg)
```
#### 2. 立即响应函数 (`app/module/f6_plugin_handlers.py`)
```python
@staticmethod
def bi_task(data: Dict[str, Any]) -> Dict[str, str]:
"""BI任务立即响应函数"""
entry_data = api_instance.entry_data_get(data=data)
# 获取参数
username = entry_data['data'].get('账号')
password = entry_data['data'].get('密码')
company_name = entry_data['data'].get('公司名称')
save_path = entry_data['data'].get('文件保存地址')
# 登录(如果需要)
cookies = None
if username and password and company_name:
login_response = F6Module.login_in(username, password, company_name)
if login_response is None:
return {'msg': '登录失败'}
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
# 读取文件(如果需要)
df = None
if save_path:
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
# 启动后台线程
thread = threading.Thread(target=bi_task_background,
args=(data, cookies, df, save_path))
thread.start()
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
```
#### 3. 注册操作 (`main.py`)
```python
core_manager.register_action('bi_task', f6_plugin_handlers.bi_task, 'f6_plugin_module',
description='BI任务')
```
---
## 文件结构说明
### 关键文件位置
```
fastapi_app/
├── app/
│ ├── module/
│ │ └── f6_plugin_handlers.py # 立即响应处理器(重命名自 f6_plugin_module.py
│ │
│ ├── tasks/
│ │ ├── __init__.py # 任务导出文件
│ │ ├── common.py # 通用任务函数
│ │ ├── bi_tasks.py # BI任务(示例)
│ │ ├── brand_tasks.py # 品牌任务
│ │ ├── customer_tasks.py # 客户任务
│ │ └── delete_tasks.py # 删除任务
│ │
│ └── api/
│ └── routes.py # API路由
└── main.py # 应用入口,注册所有操作
```
### 命名规范
- **立即响应函数**:位于 `F6PluginHandlers` 类中,使用小写下划线命名(如 `bi_task`
- **后台执行函数**:位于 `app/tasks/` 目录,使用 `{task_name}_background` 命名(如 `bi_task_background`
- **操作名称**:在 `main.py` 中注册时使用,通常与立即响应函数名相同(如 `'bi_task'`
---
## 注意事项
### 1. 文件命名变更
**重要**`f6_plugin_module.py` 已重命名为 `f6_plugin_handlers.py`,类名从 `F6PluginModule` 改为 `F6PluginHandlers`
- 文件名更清晰地表达了其功能:处理立即响应的处理器
- 所有引用已更新,但 `app.state.f6_plugin_module` 保持向后兼容
### 2. 参数传递
后台执行函数通常接收以下参数:
- `data`: 必需,包含简道云表单信息
- `cookies`: 可选,F6系统登录凭证
- `df`: 可选,Excel文件数据(DataFrame
- `save_path`: 可选,文件保存路径
### 3. 错误处理
- 立即响应函数:应捕获登录、文件读取等错误,立即返回错误信息
- 后台执行函数:应使用 try-except 包裹整个逻辑,确保错误能更新到简道云表单
### 4. 文件清理
如果任务处理了上传的文件,应在执行完成后删除:
```python
if save_path and os.path.exists(save_path):
os.remove(save_path)
logger.info(f'{save_path}已删除')
```
### 5. 简道云表单更新
所有后台任务完成后都应:
1. 调用 `update_jiandaoyun(data, results_str)` 更新执行结果
2. 如果更新成功,调用 `approve_workflow(data)` 自动提交工作流
### 6. 日志记录
使用项目统一的日志记录器:
```python
import logging
logger = logging.getLogger('app')
logger.info("信息日志")
logger.error("错误日志", exc_info=True) # exc_info=True 记录异常堆栈
```
### 7. 进度显示
对于批量处理任务,使用 `tqdm` 显示进度:
```python
from tqdm import tqdm
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理数据"):
# 处理逻辑
```
---
## 快速检查清单
添加新任务类时,请确认:
- [ ] 创建了后台任务文件 `app/tasks/{task_name}_tasks.py`
- [ ] 在 `app/tasks/__init__.py` 中导出了后台任务函数
- [ ] 在 `app/module/f6_plugin_handlers.py` 中添加了立即响应函数
- [ ] 在 `main.py` 中注册了操作
- [ ] 实现了错误处理逻辑
- [ ] 添加了日志记录
- [ ] 实现了文件清理(如果处理了文件)
- [ ] 实现了简道云表单更新和工作流提交
---
## 常见问题
### Q: 如何测试新添加的任务?
A: 启动应用后,通过API调用测试:
- 请求头设置 `Action: your_task`
- 请求体包含简道云表单数据
### Q: 任务执行失败怎么办?
A: 后台执行函数中的异常会被捕获,错误信息会更新到简道云表单的"执行明细"字段。
### Q: 如何修改任务执行逻辑?
A: 只需修改 `app/tasks/{task_name}_tasks.py` 中的后台执行函数即可。
### Q: 可以添加不需要登录的任务吗?
A: 可以,在立即响应函数中不调用 `F6Module.login_in``cookies` 参数传 `None` 即可。
---
## 相关文件
- `app/module/f6_plugin_handlers.py` - 立即响应处理器
- `app/tasks/common.py` - 通用任务函数(update_jiandaoyun, approve_workflow
- `app/core/module_registry.py` - 模块注册表
- `main.py` - 应用入口和操作注册
---
**最后更新**: 2025年
**维护者**: 数据组
+685
View File
@@ -0,0 +1,685 @@
# 简道云 FastAPI 服务系统说明书
## 1. 系统概述
### 1.1 系统简介
简道云 FastAPI 服务是一个基于 FastAPI 框架开发的后端服务系统,主要用于处理简道云插件发送的业务请求,与 F6 汽车维修管理系统进行数据交互,实现数据同步、批量处理、信息管理等核心功能。
### 1.2 系统定位
- **服务对象**: 简道云插件前端
- **集成系统**: F6 汽车维修管理系统、简道云平台
- **主要功能**: 数据同步、批量操作、信息管理、工作流自动化
### 1.3 系统特点
- **高性能**: 基于 FastAPI 异步框架,支持高并发请求
- **模块化**: 采用模块化设计,易于扩展和维护
- **可靠性**: 完善的异常处理和重试机制
- **自动化**: 支持后台任务自动执行和工作流自动提交
- **可维护性**: 清晰的代码结构和完善的日志记录
## 2. 系统功能
### 2.1 F6系统集成功能
#### 2.1.1 登录认证
**功能描述**: 实现 F6 系统的登录认证,支持用户名密码登录和验证码识别。
**主要流程**:
1. 接收用户名、密码、公司名称
2. 对密码进行 MD5 加密
3. 发送登录请求到 F6 系统
4. 如果触发验证码,自动识别并重试
5. 选择指定公司并完成登录
6. 返回登录结果和 Cookie 信息
**技术实现**:
- 使用 `requests` 库发送 HTTP 请求
- 使用 `pytesseract` 进行验证码 OCR 识别
- 使用 `PIL` 进行图像预处理(对比度、亮度调整)
#### 2.1.2 公司信息获取
**功能描述**: 获取 F6 系统中用户可访问的公司列表,并保存到简道云表单。
**主要流程**:
1. 使用用户名密码登录 F6 系统
2. 获取公司列表(单店或多店)
3. 将公司信息批量写入简道云表单
4. 返回时间戳用于后续查询
**输出格式**:
- 单店: 返回门店名称
- 多店: 返回所有公司名称列表
#### 2.1.3 门店信息获取
**功能描述**: 获取指定公司下的门店列表及统计数据。
**主要流程**:
1. 登录 F6 系统并选择公司
2. 获取门店列表
3. 统计客户车辆数量和客户数量
4. 将门店信息批量写入简道云表单
5. 返回时间戳和统计数据
**输出内容**:
- 门店名称列表
- 客户车辆总数
- 客户总数
#### 2.1.4 保持连接
**功能描述**: 心跳检测功能,用于保持连接活跃。
**实现**: 直接返回接收到的数据,不做任何处理。
### 2.2 文件处理功能
#### 2.2.1 文件上传和下载
**功能描述**: 处理简道云插件上传的文件,下载并保存到本地。
**主要流程**:
1. 从简道云获取文件 URL
2. 解析文件名和扩展名
3. 根据时间戳生成唯一文件名
4. 下载文件到 `下载文件/` 目录
5. 返回文件保存路径
**文件命名规则**: `原文件名_时间戳.扩展名`
#### 2.2.2 文件校验
**功能描述**: 校验上传的 Excel 文件格式是否符合要求。
**支持的操作类型**:
- `create_brand`: 校验第一列是否为"品牌"
- `modify_customer_info`: 校验第一列是否为"客户手机号"
- `delete_cars`: 暂不校验
**校验流程**:
1. 读取 Excel 文件第一列
2. 检查表头是否符合要求
3. 返回校验结果(成功/失败)
### 2.3 数据管理功能
#### 2.3.1 品牌批量创建
**功能描述**: 从 Excel 文件读取品牌名称,批量创建到 F6 系统。
**主要流程**:
1. 从简道云获取任务数据(账号、密码、公司名称、文件路径)
2. 登录 F6 系统
3. 读取 Excel 文件(第一列为品牌名称)
4. 在后台线程中批量创建品牌
5. 立即返回"正在执行"提示
6. 后台任务完成后更新简道云表单并自动提交工作流
**后台任务处理**:
- 遍历 Excel 文件中的每一行
- 调用 F6 API 创建品牌
- 记录成功和失败的结果
- 执行完成后删除上传的文件
- 更新简道云表单的执行明细
- 自动提交工作流到下一步
#### 2.3.2 客户信息修改
**功能描述**: 从 Excel 文件读取客户修改信息,批量修改 F6 系统中的客户信息。
**主要流程**:
1. 从简道云获取任务数据
2. 登录 F6 系统
3. 读取 Excel 文件(包含客户手机号、修改字段等)
4. 获取所有客户列表
5. 获取员工列表(用于匹配专属运营顾问)
6. 在后台线程中批量修改客户信息
7. 立即返回"正在执行中"提示
**Excel 文件格式**:
- 第一列: 客户手机号(必需,用于匹配)
- 其他列: 需要修改的字段(客户姓名、客户类型、客户来源、单位名称、专属运营顾问、客户备注等)
**匹配逻辑**:
- 根据客户手机号匹配 F6 系统中的客户
- 获取客户原始信息
- 合并 Excel 中的修改字段
- 解析地址信息(省市区)
- 匹配专属运营顾问的用户ID
#### 2.3.3 客户信息删除
**功能描述**: 批量删除 F6 系统中的客户信息。
**主要流程**:
1. 从简道云获取任务数据
2. 登录 F6 系统
3. 获取所有客户列表
4. 获取会员卡列表(提取客户ID
5. 在后台线程中批量删除客户
6. 立即返回"正在执行中"提示
**删除规则**:
- 跳过有最近消费时间的客户
- 跳过有会员卡的客户
- 8-20点之间每3.5秒删除一条,其余时间每1.5秒删除一条
- 记录成功和失败次数
#### 2.3.4 车辆信息删除
**功能描述**: 批量删除 F6 系统中的客户车辆信息。
**主要流程**:
1. 从简道云获取任务数据
2. 登录 F6 系统
3. 分页获取所有车辆列表
4. 获取会员卡列表(提取车辆ID
5. 在后台线程中批量删除车辆
6. 立即返回"正在执行中"提示
**删除规则**:
- 跳过有最近消费时间的客户车辆
- 跳过有会员卡的车辆
- 8-20点之间每3秒删除一条,其余时间每1秒删除一条
- 记录成功和失败次数
#### 2.3.5 历史记录删除
**功能描述**: 删除指定门店的历史维修记录。
**主要流程**:
1. 从简道云获取任务数据(账号、密码、公司名称、门店名称)
2. 登录 F6 系统
3. 获取门店列表并匹配门店ID
4. 在后台线程中删除历史维修记录
5. 立即返回"正在执行中"提示
**删除方式**: 调用 F6 系统的删除接口,删除整个门店的历史维修记录。
### 2.4 BI任务功能
**功能描述**: 执行 BI 相关的数据处理和报表生成任务。
**当前状态**: 框架已搭建,具体业务逻辑待实现。
**预留接口**:
- 支持从 Excel 文件读取数据
- 支持 F6 系统登录(如需要)
- 支持后台线程执行
- 支持结果回写到简道云
### 2.5 工作流自动化
**功能描述**: 自动获取简道云工作流的待处理任务并提交到下一步。
**主要流程**:
1. 获取工作流实例信息
2. 查找状态为待处理(status=0)的任务
3. 提取任务信息(username、instance_id、task_id
4. 调用审批接口自动提交
5. 记录执行结果
**应用场景**: 后台任务执行完成后,自动将简道云表单提交到工作流下一步,无需人工干预。
## 3. 技术架构
### 3.1 系统架构
```
┌─────────────────┐
│ 简道云插件前端 │
└────────┬────────┘
│ HTTP/JSON
┌─────────────────┐
│ FastAPI 服务 │
│ ┌───────────┐ │
│ │ 路由层 │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ 业务模块 │ │
│ │ - F6Module│ │
│ │ - Plugin │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ 任务队列 │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ 后台任务 │ │
│ └───────────┘ │
└────────┬────────┘
┌────┴────┐
▼ ▼
┌────────┐ ┌────────┐
│ F6系统 │ │ 简道云 │
└────────┘ └────────┘
```
### 3.2 核心组件
#### 3.2.1 应用入口 (main.py)
**职责**:
- 应用初始化和生命周期管理
- 模块注册和路由配置
- 异常处理器配置
- 中间件配置(CORS
**关键功能**:
- `lifespan`: 管理应用启动和关闭
- 全局异常处理
- 模块实例化并注册到注册表
#### 3.2.2 路由层 (app/api/routes.py)
**职责**:
- 定义 API 端点
- 请求验证和参数解析
- 调用业务模块处理请求
- 返回响应
**主要端点**:
- `GET /health`: 健康检查
- `POST /webhook`: 业务请求处理
#### 3.2.3 业务模块层
**F6Module** (`app/module/module.py`):
- F6 系统登录和认证
- 公司信息获取
- 门店信息获取
- 保持连接
**F6PluginModule** (`app/module/f6_plugin_module.py`):
- 文件上传和校验
- 品牌批量创建
- 客户信息管理(修改、删除)
- 车辆信息删除
- 历史记录删除
- BI任务
**OtherPluginModule** (`app/module/other_module.py`):
- 其他插件功能(如短信签名状态)
#### 3.2.4 任务处理层
**任务队列** (`app/utils/app_tools.py`):
- 使用 `Queue` 实现任务队列
- 后台线程处理任务
- 支持同步等待结果
**后台任务** (`app/tasks/`):
- `common.py`: 通用任务函数(更新简道云、工作流审批等)
- `brand_tasks.py`: 品牌相关任务
- `customer_tasks.py`: 客户相关任务
- `delete_tasks.py`: 删除相关任务
- `bi_tasks.py`: BI相关任务
#### 3.2.5 核心工具层
**模块注册表** (`app/core/module_registry.py`):
- 统一管理所有业务操作
- 支持操作查询和路由
- 存储操作元数据(描述、模块名等)
**应用工具** (`app/utils/app_tools.py`):
- 日志记录器配置(支持轮转)
- 任务队列管理
- 请求头解码工具
- 后台调度器
**简道云API封装** (`app/api/__init__.py`):
- 封装简道云所有API接口
- 支持失败重试机制
- 字段ID到标签名的替换
- 批量操作支持
### 3.3 数据流
#### 3.3.1 同步任务流程
```
简道云插件 → FastAPI路由 → 业务模块 → 任务队列 → 处理线程 → 返回结果 → 简道云插件
```
#### 3.3.2 后台任务流程
```
简道云插件 → FastAPI路由 → 业务模块 → 启动后台线程 → 立即返回
后台任务执行
更新简道云表单
自动提交工作流
```
### 3.4 技术选型
| 技术 | 版本 | 用途 |
|------|------|------|
| FastAPI | 0.121.0 | Web框架 |
| Uvicorn | 0.38.0 | ASGI服务器 |
| Pydantic | - | 数据验证 |
| Requests | 2.32.5 | HTTP请求 |
| Pandas | 2.3.3 | Excel处理 |
| APScheduler | 3.11.1 | 任务调度 |
| Pillow | 12.0.0 | 图像处理 |
| Pytesseract | 0.3.13 | OCR识别 |
## 4. 部署运维
### 4.1 环境要求
- **操作系统**: Windows/Linux/macOS
- **Python版本**: 3.8+
- **内存**: 建议 2GB+
- **磁盘**: 根据文件存储需求,建议 10GB+
### 4.2 安装步骤
1. **克隆代码**:
```bash
git clone <repository_url>
cd fastapi_app
```
2. **安装依赖**:
```bash
pip install -r requirements.txt
```
3. **配置环境**:
- 编辑 `app/config.py`,配置 API Token
- 确保目录权限正确(logs、下载文件、模板文件)
4. **启动服务**:
```bash
python main.py
# 或
uvicorn main:app --host 0.0.0.0 --port 5003
```
### 4.3 生产环境部署
#### 4.3.1 使用 Gunicorn + Uvicorn Workers
```bash
pip install gunicorn
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:5003
```
#### 4.3.2 使用 systemd 管理服务(Linux
创建服务文件 `/etc/systemd/system/fastapi-app.service`:
```ini
[Unit]
Description=简道云 FastAPI 服务
After=network.target
[Service]
Type=simple
User=your_user
WorkingDirectory=/path/to/fastapi_app
Environment="PATH=/path/to/venv/bin"
ExecStart=/path/to/venv/bin/uvicorn main:app --host 0.0.0.0 --port 5003
Restart=always
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl enable fastapi-app
sudo systemctl start fastapi-app
```
#### 4.3.3 使用 Nginx 反向代理
Nginx 配置示例:
```nginx
server {
listen 80;
server_name your_domain.com;
location / {
proxy_pass http://127.0.0.1:5003;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
### 4.4 监控和日志
#### 4.4.1 日志管理
- **日志位置**: `logs/简道云.log`
- **日志轮转**: 单文件最大 5MB,保留 5 个备份
- **日志级别**: INFO
- **日志格式**: `时间戳 级别:模块名:消息`
#### 4.4.2 健康检查
定期访问 `/health` 端点检查服务状态:
```bash
curl http://localhost:5003/health
```
#### 4.4.3 性能监控
建议使用以下工具监控服务性能:
- **APM工具**: New Relic、Datadog 等
- **日志分析**: ELK Stack、Loki 等
- **指标监控**: Prometheus + Grafana
### 4.5 备份和恢复
#### 4.5.1 数据备份
需要备份的内容:
- 配置文件 (`app/config.py`)
- 日志文件 (`logs/`)
- 下载的文件 (`下载文件/`)
- 模板文件 (`模板文件/`)
#### 4.5.2 恢复步骤
1. 恢复代码和配置文件
2. 恢复数据文件
3. 重新安装依赖
4. 重启服务
## 5. 安全说明
### 5.1 认证和授权
- **API Token**: 使用简道云 API Token 进行认证
- **F6系统**: 使用用户名密码登录,密码进行 MD5 加密
### 5.2 数据安全
- **敏感信息**: API Token 建议使用环境变量管理
- **密码处理**: 密码在传输前进行 MD5 加密
- **文件存储**: 上传的文件在处理完成后自动删除
### 5.3 网络安全
- **CORS配置**: 生产环境建议限制允许的来源
- **HTTPS**: 建议使用 HTTPS 加密传输
- **防火墙**: 限制服务端口访问
### 5.4 安全建议
1. 定期更新依赖包,修复安全漏洞
2. 使用强密码策略
3. 定期审查日志,发现异常访问
4. 限制文件上传大小和类型
5. 实施请求频率限制
## 6. 故障排查
### 6.1 常见问题
#### 问题1: 服务启动失败
**可能原因**:
- 端口被占用
- 依赖包未安装
- 配置文件错误
**解决方法**:
- 检查端口占用: `netstat -ano | findstr 5003`
- 重新安装依赖: `pip install -r requirements.txt`
- 检查配置文件格式
#### 问题2: 登录失败
**可能原因**:
- 用户名密码错误
- 公司名称不正确
- 验证码识别失败
- F6系统服务异常
**解决方法**:
- 验证用户名密码和公司名称
- 检查 Tesseract OCR 是否正确安装
- 查看日志获取详细错误信息
#### 问题3: 文件上传失败
**可能原因**:
- 文件格式不正确
- 文件路径不存在
- 磁盘空间不足
- 网络连接问题
**解决方法**:
- 检查文件格式是否符合要求
- 确保目录存在且有写权限
- 检查磁盘空间
- 查看网络连接状态
#### 问题4: 后台任务未执行
**可能原因**:
- 线程启动失败
- 任务执行异常
- 简道云API调用失败
**解决方法**:
- 查看日志中的错误信息
- 检查简道云API Token是否有效
- 验证任务数据格式是否正确
### 6.2 日志分析
查看日志文件:
```bash
tail -f logs/简道云.log
```
搜索错误:
```bash
grep -i error logs/简道云.log
```
### 6.3 性能优化
1. **并发处理**: 调整 Uvicorn workers 数量
2. **数据库连接池**: 如使用数据库,配置连接池
3. **缓存**: 对频繁访问的数据使用缓存
4. **异步处理**: 更多使用异步函数提高性能
## 7. 扩展开发
### 7.1 添加新功能模块
1. 在 `app/module/` 下创建新模块文件
2. 实现业务逻辑方法
3. 在 `main.py` 中注册模块和操作
4. 在 `app/schemas.py` 中添加数据模型(如需要)
### 7.2 添加新的API端点
1. 在 `app/api/routes.py` 中添加路由
2. 使用依赖注入获取所需服务
3. 实现业务逻辑
4. 返回响应
### 7.3 添加新的后台任务
1. 在 `app/tasks/` 下创建任务文件
2. 实现后台任务函数
3. 在业务模块中调用,启动线程
4. 使用 `update_jiandaoyun` 更新结果
5. 使用 `approve_workflow` 自动提交工作流
## 8. 附录
### 8.1 API接口清单
| 端点 | 方法 | 描述 |
|------|------|------|
| `/health` | GET | 健康检查 |
| `/webhook` | POST | 业务请求处理 |
### 8.2 操作类型清单
| 操作名 | 模块 | 类型 |
|--------|------|------|
| `login_in` | F6Module | 同步 |
| `get_company_information` | F6Module | 同步 |
| `get_store_information` | F6Module | 同步 |
| `keep_alive` | F6Module | 同步 |
| `check_file` | F6PluginModule | 同步 |
| `create_brand` | F6PluginModule | 后台 |
| `delete_history` | F6PluginModule | 后台 |
| `delete_customer` | F6PluginModule | 后台 |
| `delete_cars` | F6PluginModule | 后台 |
| `modify_customer_info` | F6PluginModule | 后台 |
| `bi_task` | F6PluginModule | 后台 |
| `sms_signature_status` | OtherPluginModule | 同步 |
### 8.3 配置文件说明
**app/config.py**:
- `BASE_DIR`: 项目根目录
- `SAVE_DIRECTORY`: 下载文件目录
- `MODE_DIRECTORY`: 模板文件目录
- `LOGS_DIRECTORY`: 日志目录
- `LOG_FILE`: 日志文件路径
- `JIANDAOYUN_API_TOKEN`: 简道云API Token
### 8.4 目录结构说明
- `logs/`: 日志文件存储
- `下载文件/`: 从简道云下载的文件临时存储
- `模板文件/`: 模板文件存储
- `app/`: 应用代码目录
- `app/api/`: API相关代码
- `app/core/`: 核心功能代码
- `app/module/`: 业务模块代码
- `app/tasks/`: 后台任务代码
- `app/utils/`: 工具类代码
---
**文档版本**: 2.0.0
**最后更新**: 2025年
**维护团队**: 数据组
+197 -69
View File
@@ -1,96 +1,224 @@
from fastapi import FastAPI, Request
"""
简道云 FastAPI 服务 - 主应用入口
本文件是 FastAPI 应用的主入口文件负责
1. 应用初始化和生命周期管理
2. 模块注册和路由配置
3. 异常处理器配置
4. 中间件配置
作者: 项目团队
版本: 2.0.0
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException, status
from fastapi.responses import JSONResponse
import json
import anyio
from fastapi.exceptions import RequestValidationError
import logging
from app.utils.app_tools import AppTools, setup_global_logger
from app.module.F6_Plugin_module import F6PluginModule
from app.module.f6_plugin_module import F6PluginModule
from app.module.module import F6Module
from app.module.other_module import OtherPluginModule
from app.config import Config
from app.schemas import ErrorResponse
from app.core import core_manager
from app.api.routes import router
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="简道云FastAPI服务")
@app.on_event("startup")
def on_startup():
"""应用启动时初始化"""
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理 - 启动和关闭"""
# 启动时初始化
app.state.app_tools = AppTools(Config)
app.state.logger = setup_global_logger(Config)
app.state.f6_module = F6Module()
app.state.f6_plugin_module = F6PluginModule()
app.state.other_module = OtherPluginModule()
# 初始化业务模块
f6_module = F6Module()
f6_plugin_module = F6PluginModule()
other_module = OtherPluginModule()
# 将模块实例存储到 app.state(用于依赖注入)
app.state.f6_module = f6_module
app.state.f6_plugin_module = f6_plugin_module
app.state.other_module = other_module
# 注册模块到 registry
core_manager.initialize_modules({
'f6_module': f6_module,
'f6_plugin_module': f6_plugin_module,
'other_module': other_module
})
# 注册所有操作到 module_registry
core_manager.register_action('login_in', f6_module.accept_login_message, 'f6_module',
description='F6系统登录')
core_manager.register_action('get_company_information', f6_module.get_company_information, 'f6_module',
description='获取公司信息')
core_manager.register_action('get_store_information', f6_module.get_store_information, 'f6_module',
description='获取门店信息')
core_manager.register_action('keep_alive', f6_module.get_keep_heart, 'f6_module',
description='保持连接')
core_manager.register_action('check_file', f6_plugin_module.check_file, 'f6_plugin_module',
description='校验上传文件')
core_manager.register_action('create_brand', f6_plugin_module.create_brand, 'f6_plugin_module',
description='创建品牌')
core_manager.register_action('delete_history', f6_plugin_module.delete_history, 'f6_plugin_module',
description='删除历史记录')
core_manager.register_action('delete_customer', f6_plugin_module.delete_customer, 'f6_plugin_module',
description='删除客户')
core_manager.register_action('delete_cars', f6_plugin_module.delete_cars, 'f6_plugin_module',
description='删除车辆')
core_manager.register_action('sms_signature_status', other_module.sms_signature_status, 'other_module',
description='短信签名状态')
core_manager.register_action('modify_customer_info', f6_plugin_module.modify_customer_info, 'f6_plugin_module',
description='修改客户信息')
core_manager.register_action('disable_project', f6_plugin_module.disable_projects, 'f6_plugin_module',
description='项目信息批量启停')
core_manager.register_action('batch_modify_materials', f6_plugin_module.modify_material, 'f6_plugin_module',
description='材料信息批量修改')
core_manager.register_action('batch_modify_projects', f6_plugin_module.modify_project, 'f6_plugin_module',
description='项目信息批量修改')
core_manager.register_action('bi_task', f6_plugin_module.bi_task, 'f6_plugin_module',
description='BI任务')
app.state.logger.info("应用启动完成,已注册所有操作")
yield
# 关闭时清理资源
if hasattr(app.state, 'app_tools') and hasattr(app.state.app_tools, 'scheduler'):
app.state.app_tools.scheduler.shutdown(wait=False)
app.state.logger.info("应用关闭")
app = FastAPI(
title="简道云FastAPI服务",
description="简道云插件后端服务,提供数据同步和处理功能",
version="2.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(router)
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
@app.get("/", tags=["系统"])
async def root():
"""
根路径端点
返回服务基本信息和可用端点
"""
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,
"service": "简道云FastAPI服务",
"version": "2.0.0",
"status": "running",
"endpoints": {
"health": "/health",
"webhook": "/webhook",
"docs": "/docs",
"redoc": "/redoc"
}
}
@app.post("/webhook")
async def webhook(request: Request):
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
接受前端请求后将任务放入消息队列
HTTP 异常处理器
处理所有 HTTPException 异常返回统一的错误响应格式
Args:
request: FastAPI 请求对象
exc: HTTPException 异常对象
Returns:
any: 返回任务处理的结果
JSONResponse: 包含错误详情的 JSON 响应
"""
logger = app.state.logger
app_tools: AppTools = app.state.app_tools
logger = getattr(app.state, 'logger', None)
if logger:
logger.error(f"HTTP异常: {exc.status_code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
detail=exc.detail or "HTTP error",
error_code=f"HTTP_{exc.status_code}"
).model_dump(),
)
# 获取请求数据
data = await request.json()
header = request.headers
# 解码请求头
decoded_header = app_tools.decode_headers(header)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""
请求验证异常处理器
处理 Pydantic 数据验证失败的情况返回详细的验证错误信息
Args:
request: FastAPI 请求对象
exc: RequestValidationError 异常对象
Returns:
JSONResponse: 包含验证错误详情的 JSON 响应
"""
logger = getattr(app.state, 'logger', None)
if logger:
logger.warning(f"请求验证失败: {exc.errors()}")
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=ErrorResponse(
detail="请求数据验证失败",
error_code="VALIDATION_ERROR"
).model_dump(),
)
# 获取操作映射表
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)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""
通用异常处理器
捕获所有未处理的异常防止应用崩溃并记录详细的错误信息
Args:
request: FastAPI 请求对象
exc: 异常对象
Returns:
JSONResponse: 包含错误详情的 JSON 响应
"""
logger = getattr(app.state, 'logger', None)
if logger:
logger.error(f"未处理的异常: {type(exc).__name__} - {str(exc)}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=ErrorResponse(
detail="服务器内部错误",
error_code="INTERNAL_ERROR"
).model_dump(),
)
# 路由已移动到 app/api/routes.py
if __name__ == '__main__':
"""
直接运行入口
当直接运行此文件时启动 uvicorn 服务器
默认配置
- 主机: 0.0.0.0 (监听所有网络接口)
- 端口: 5000
- 热重载: 关闭 (生产环境建议关闭)
"""
import uvicorn
uvicorn.run("fastapi_app.main:app", host="0.0.0.0", port=5003, reload=False)
uvicorn.run(app, host="0.0.0.0", port=5000, reload=False)
+8 -7
View File
@@ -1,11 +1,12 @@
anyio==4.11.0
apscheduler==3.11.1
fastapi==0.121.0
log_config==2.1.1
numpy==2.3.4
anyio==4.12.0
apscheduler==3.11.2
fastapi==0.128.0
numpy==2.4.0
pandas==2.3.3
Pillow==12.0.0
Pillow==12.1.0
pydantic==2.12.5
pytesseract==0.3.13
Requests==2.32.5
tqdm==4.67.1
uvicorn==0.38.0
uvicorn==0.40.0
openpyxl
+54
View File
@@ -0,0 +1,54 @@
@echo off
title 简道云监听服务 端口5000
:: 切换到应用所在目录(与原始脚本一致)
cd /d "C:\Users\Administrator\Desktop\简道云\简道云"
:: 激活Anaconda基础环境(或你指定的环境)
call C:\ProgramData\anaconda3\Scripts\activate.bat
if %errorlevel% neq 0 (
echo 激活Anaconda环境失败,请检查环境配置。
pause
exit /b 1
)
:: 验证Python是否可用
python --version >nul 2>&1
if %errorlevel% neq 0 (
echo 无法找到Python,请检查Anaconda环境是否正确安装。
pause
exit /b 1
)
:: 验证uvicorn是否已安装(可选但推荐)
python -c "import uvicorn" >nul 2>&1
if %errorlevel% neq 0 (
echo 错误:未检测到 uvicorn。请运行以下命令安装:
echo conda install -c conda-forge uvicorn fastapi
echo
echo pip install "uvicorn[standard]" fastapi
pause
exit /b 1
)
:: 设置默认参数(允许通过环境变量覆盖)
if "%HOST%"=="" set HOST=0.0.0.0
if "%PORT%"=="" set PORT=5000
if "%WORKERS%"=="" set WORKERS=1
if "%LOG_LEVEL%"=="" set LOG_LEVEL=info
set APP_MODULE=main:app
echo.
echo 启动简道云 FastAPI 服务...
echo 模块: %APP_MODULE%
echo 地址: http://%HOST%:%PORT%
echo 进程数: %WORKERS% Windows建议设为1
echo 日志级别: %LOG_LEVEL%
echo.
:: 使用 python -m uvicorn 确保调用当前环境中的 uvicorn
python -m uvicorn %APP_MODULE% --host %HOST% --port %PORT% --workers %WORKERS% --log-level %LOG_LEVEL%
:: 服务退出后暂停,便于查看日志
pause
+49
View File
@@ -0,0 +1,49 @@
import requests
cookies = {
'memberSESSIONID': '43f77327-7a6b-4844-a1c6-d1acd7e0c970',
'erpLanguage': 'zh-CN',
'prodOrg': '11240984669917217520',
'unp': '15865484595890778191',
'un': '15865484595890778191',
'_up': '-NillNN-qyBEJ--t3vnSknvoOF53y_SJuMkA2n43U-daUfnArpjQjaZJ9Q3d-WrAAGgt60MgQHajHWBHMKKxj0CuWypi1JgKCFP1EPEk-HbqEvcTrYkr0wcI-fBRv-ZNHu3M-GTc1p60EX-sq-RQgeIal1HLPxpurEj9mEe9rIrrcGQ.',
'sensorsdata2015jssdkcross': '%7B%22distinct_id%22%3A%2215865484595890778191%22%2C%22first_id%22%3A%2219a48e066e68e2-067b1e693596828-4c657b58-2073600-19a48e066e71500%22%2C%22props%22%3A%7B%22%24latest_traffic_source_type%22%3A%22%E7%9B%B4%E6%8E%A5%E6%B5%81%E9%87%8F%22%2C%22%24latest_search_keyword%22%3A%22%E6%9C%AA%E5%8F%96%E5%88%B0%E5%80%BC_%E7%9B%B4%E6%8E%A5%E6%89%93%E5%BC%80%22%2C%22%24latest_referrer%22%3A%22%22%7D%2C%22%24device_id%22%3A%2219a48e066e68e2-067b1e693596828-4c657b58-2073600-19a48e066e71500%22%7D',
'tmall': 'false',
'Hm_lvt_25f5e7a3a5dbb293d7dd35d5f1be8d0a': '1764311728,1764643482,1764662823,1764742943',
'Hm_lpvt_25f5e7a3a5dbb293d7dd35d5f1be8d0a': '1764742943',
'HMACCOUNT': '55F2182717FD6AE6',
}
headers = {
'accept': 'application/json, text/plain, */*',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'cache-control': 'no-cache',
'pragma': 'no-cache',
'priority': 'u=1, i',
'referer': 'https://yunxiu.f6car.cn/erp/view/index.html',
'sec-ch-ua': '"Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'traceparent': '00-b018ff54506f4db58f1c139e9cb30525-22df753b9da6e050-01',
'tracestate': 'rum=v2&browser&dz2uw0c5ay@e5930ea8eb782ae&273f4c9d3aeb40c586714fd3270a28e9&uid_48oaftj52d5ybkwt',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0',
'x-requested-with': 'XMLHttpRequest',
# 'cookie': 'memberSESSIONID=43f77327-7a6b-4844-a1c6-d1acd7e0c970; erpLanguage=zh-CN; prodOrg=11240984669917217520; unp=15865484595890778191; un=15865484595890778191; _up=-NillNN-qyBEJ--t3vnSknvoOF53y_SJuMkA2n43U-daUfnArpjQjaZJ9Q3d-WrAAGgt60MgQHajHWBHMKKxj0CuWypi1JgKCFP1EPEk-HbqEvcTrYkr0wcI-fBRv-ZNHu3M-GTc1p60EX-sq-RQgeIal1HLPxpurEj9mEe9rIrrcGQ.; sensorsdata2015jssdkcross=%7B%22distinct_id%22%3A%2215865484595890778191%22%2C%22first_id%22%3A%2219a48e066e68e2-067b1e693596828-4c657b58-2073600-19a48e066e71500%22%2C%22props%22%3A%7B%22%24latest_traffic_source_type%22%3A%22%E7%9B%B4%E6%8E%A5%E6%B5%81%E9%87%8F%22%2C%22%24latest_search_keyword%22%3A%22%E6%9C%AA%E5%8F%96%E5%88%B0%E5%80%BC_%E7%9B%B4%E6%8E%A5%E6%89%93%E5%BC%80%22%2C%22%24latest_referrer%22%3A%22%22%7D%2C%22%24device_id%22%3A%2219a48e066e68e2-067b1e693596828-4c657b58-2073600-19a48e066e71500%22%7D; tmall=false; Hm_lvt_25f5e7a3a5dbb293d7dd35d5f1be8d0a=1764311728,1764643482,1764662823,1764742943; Hm_lpvt_25f5e7a3a5dbb293d7dd35d5f1be8d0a=1764742943; HMACCOUNT=55F2182717FD6AE6',
}
params = {
'pageSize': '10',
'pageNo': '1',
}
response = requests.get(
'https://yunxiu.f6car.cn/member/customer/listForPermission',
params=params,
cookies=cookies,
headers=headers,
)
print(response.json())
-97
View File
@@ -1,97 +0,0 @@
import logging
import os
from logging.handlers import RotatingFileHandler
from queue import Queue
import threading
from apscheduler.schedulers.background import BackgroundScheduler
from urllib.parse import unquote
class AppTools:
def __init__(self, config):
self.config = config
self.task_queue = Queue()
self.logger = self._setup_logger()
self.scheduler = self._setup_scheduler()
self._start_task_thread()
def _setup_logger(self):
"""配置日志记录器(不依赖 Flask)。"""
log_dir = self.config.LOGS_DIRECTORY
if not os.path.exists(log_dir):
os.makedirs(log_dir)
log_file = self.config.LOG_FILE
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
# 防止重复添加 handler
if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == str(log_file)
for h in logger.handlers):
file_handler = RotatingFileHandler(log_file, maxBytes=1024 * 1024 * 5, backupCount=5, encoding='utf-8')
file_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s %(levelname)s:%(name)s:%(message)s')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def _setup_scheduler(self):
"""配置后台调度器"""
scheduler = BackgroundScheduler()
# 如需定时任务可在此添加
import atexit
atexit.register(lambda: scheduler.shutdown(wait=False))
return scheduler
def _start_task_thread(self):
"""启动任务处理线程"""
task_thread = threading.Thread(target=self.process_tasks, daemon=True)
task_thread.start()
def process_tasks(self):
"""处理任务队列中的任务"""
while True:
task = self.task_queue.get()
if task is None:
self.logger.error("任务处理线程已终止")
break
try:
result = task['handler'](task['data'])
task['response'].put(result)
except Exception as e:
self.logger.error(f"任务执行失败: {str(e)}")
task['response'].put({'msg': f'任务执行失败: {str(e)}'})
finally:
self.task_queue.task_done()
self.logger.info("任务处理完成")
def enqueue_task(self, handler, data):
"""将任务放入消息队列"""
response_queue = Queue()
self.task_queue.put({
'handler': handler,
'data': data,
'response': response_queue
})
return response_queue
@staticmethod
def decode_headers(headers):
"""解码包含中文字符的 HTTP 请求头"""
return {key: unquote(value, encoding='utf-8') for key, value in headers.items()}
# 简易的全局日志记录器设置(与原项目解耦)
logger = None
def setup_global_logger(config):
global logger
if logger is None:
app_tools = AppTools(config)
logger = app_tools.logger
return logger
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.