""" 简道云 API 接口封装模块 本模块封装了简道云的所有 API 接口,包括: - 表单数据操作(获取、创建、更新、删除) - 表单字段获取 - 工作流操作(获取实例、审批、转交) - 文件操作(获取上传凭证、上传文件) 特性: - 支持失败重试机制(默认最多重试20次) - 支持字段替换(将字段ID替换为标签名) - 支持批量操作(批量创建、更新、删除) - 支持数据类型转换(NumPy、Decimal等) - 完善的错误处理和日志记录 """ import requests import json import time import logging from typing import Optional, List, Dict, Any from decimal import Decimal import numpy as np from app.config import Config # 获取日志记录器 logger = logging.getLogger('app') error_logger = logging.getLogger('app.error') # 错误日志记录器 class NpEncoder(json.JSONEncoder): """ NumPy 数据类型 JSON 编码器 用于将 NumPy 数据类型转换为 JSON 可序列化的类型。 支持: - np.integer -> int - np.floating -> float - np.ndarray -> list """ def default(self, obj): if isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return obj.tolist() else: return super(NpEncoder, self).default(obj) def replace_decimals(obj): """ 递归替换 Decimal 类型为 float 递归遍历数据结构,将所有 Decimal 类型替换为 float 类型, 用于 JSON 序列化。 Args: obj: 要处理的对象(可以是 dict、list 或其他类型) Returns: 处理后的对象,所有 Decimal 类型已替换为 float """ if isinstance(obj, dict): return {k: replace_decimals(v) for k, v in obj.items()} elif isinstance(obj, list): return [replace_decimals(item) for item in obj] elif isinstance(obj, Decimal): return float(obj) return obj class API: """ 简道云 API 接口封装类 提供简道云所有 API 接口的封装,包括表单操作、工作流操作、文件操作等。 所有方法都支持失败重试机制,提高可靠性。 """ def entry_data_get(self, data: dict, replace: bool = False, max_retries: int = 20) -> Dict: """ 获取单条表单数据 根据应用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' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data_id": data['data_id'] }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() print(data_get) if replace: data_get = self.field_replacement(data, data_get) return data_get except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 if retries <= max_retries: time.sleep(0.1) if retries > max_retries: error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") raise Exception(f"获取单条表单数据失败,已重试{max_retries}次") def entry_data_list(self, data: dict, replace: bool = True, max_retries: int = 20) -> Dict: """ 获取多条表单数据 :param max_retries: 最大重试次数 :param replace: 是否替换字段,默认为True(保持向后兼容) :param data: 简道云插件发送过来的data,包含应用id、表单id等信息 :return: 表单数据列表 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/list' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } all_data_batches = [] last_data_id = None exit_flag = False while True: payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "limit": 100, "data_id": last_data_id }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if data_get.get("data"): all_data_batches.extend(data_get['data']) last_data_id = data_get['data'][-1].get('_id') print(f"已获取 {len(all_data_batches)} 条数据") break else: if 'data' not in data_get or len(data_get['data']) == 0: exit_flag = True break logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(0.1) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(0.1) if retries > max_retries: error_logger.error(f"任务 {last_data_id}组 连续{max_retries}次请求失败,放弃此次请求。") all_data_batches.append(None) if exit_flag: break final_data = { 'data': all_data_batches } logger.info(f"获取了{len(all_data_batches)}条数据") if replace: print("进行了替换") return self.field_replacement(data, final_data) else: return final_data @staticmethod def entry_widget_list(data: dict, max_retries: int = 20) -> Optional[Dict[str, Any]]: """ 获取表单字段 :param max_retries: 最大重试次数 :param data: 简道云插件发送过来的data,包含应用id、表单id等信息 :return: 表单字段信息 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/widget/list' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() return res.json() except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 if retries <= max_retries: time.sleep(0.1) if retries > max_retries: error_logger.error(f"获取表单字段失败,已重试{max_retries}次") return None def field_replacement(self, data: dict, data_get: dict) -> dict: """ 字段替换,将id替换为标签名,即唯一值替换为表单中显示字段的名字 :param data: 简道云插件发送过来的data,包含表单id、数据id、应用id :param data_get: 简道云请求的数据,一般是根据数据id获取到表单的数据 :return: 替换后的数据 """ widget_list = self.entry_widget_list(data) if not widget_list or 'widgets' not in widget_list or not isinstance(widget_list['widgets'], list): raise ValueError("映射表没有接受到数据") name_to_label = {widget['name']: widget['label'] for widget in widget_list['widgets']} def replace_keys(obj): """递归替换字典中的键名""" if isinstance(obj, dict): new_dict = {} for key, value in obj.items(): new_key = name_to_label.get(key, key) new_dict[new_key] = replace_keys(value) return new_dict elif isinstance(obj, list): return [replace_keys(item) for item in obj] else: return obj data_get_copy = json.loads(json.dumps(data_get)) if 'data' in data_get_copy: data_get_copy['data'] = replace_keys(data_get_copy['data']) return data_get_copy @staticmethod def data_batch_create(data: dict, max_retries: int = 20) -> Optional[Dict]: """ 新建单条表单数据 :param max_retries: 最大重试次数 :param data: 应该包含应用id、表单id,以及新建的数据data['data'] :return: 返回创建后简道云返回的信息 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/create' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data": data['data'], "is_start_workflow": data.get('is_start_workflow', "false"), "is_start_trigger": data.get('is_start_trigger', "false"), "transaction_id": data.get('transaction_id', "") }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if res.status_code == 200: return data_get else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(3) if retries > max_retries: error_logger.error(f"任务 {data.get('data')} 连续{max_retries}次请求失败,放弃此次请求。") return None @staticmethod def entry_data_batch_create( data: dict, chunk_size: int = 90, max_retries: int = 20 ) -> List[Optional[Dict]]: """ 新建多条数据 :param max_retries: 最大重试次数 :param data: 应包含数据id、表单id、以及需要新建的信息,新建信息应该是一个列表 :param chunk_size: 简道云限制批量新建一次最多100条,这里默认值设置为90条一次 :return: 返回请求后的结果 """ data = replace_decimals(data) url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_create' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } total_length = len(data['data_list']) logger.info(f"多数据写入行数: {total_length}") num_chunks = (total_length + chunk_size - 1) // chunk_size data_get_list = [] for i in range(num_chunks): start_index = i * chunk_size end_index = min(start_index + chunk_size, total_length) payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data_list": data['data_list'][start_index:end_index], "is_start_workflow": data.get('is_start_workflow', "false"), "is_start_trigger": data.get('is_start_trigger', "false"), }, cls=NpEncoder) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if data_get.get("status") == "success": data_get_list.append(data_get) break else: logger.warning(f"请求异常,将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(0.1) if retries > max_retries: error_logger.error( f"任务 {data['data_list'][start_index:end_index]} 连续{max_retries}次请求失败,放弃此次请求。") data_get_list.append(None) return data_get_list @staticmethod def entry_data_update(data: dict, max_retries: int = 20) -> dict: """ 修改数据 :param max_retries: 最大重试次数 :param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息 :return: 修改数据后简道云返回的结果 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/update' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data_id": data['data_id'], "data": data['data'] }) data_get = None retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if res.status_code == 200: break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(10) if retries > max_retries: error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") return data_get @staticmethod def entry_data_banch_update(data: dict, max_retries: int = 20, chunk_size: int = 90) -> List[dict]: """ 批量修改数据 :param chunk_size: 批量修改块大小 :param max_retries: 最大重试次数 :param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息 :return: 修改数据后简道云返回的结果列表 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_update' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } total_length = len(data['data_ids']) logger.info(f"多数据修改行数: {total_length}") num_chunks = (total_length + chunk_size - 1) // chunk_size data_get_list = [] for i in range(num_chunks): start_index = i * chunk_size end_index = min(start_index + chunk_size, total_length) payload = { "app_id": data['api_key'], "entry_id": data['entry_id'], "data_ids": data['data_ids'][start_index:end_index], "data": data['data'] } if "transaction_id" in data: payload["transaction_id"] = data["transaction_id"] payload = json.dumps(payload, cls=NpEncoder) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if res.status_code == 200: data_get_list.append(data_get) break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(10) if retries > max_retries: error_logger.error(f"任务 {data.get('data_ids')} 连续{max_retries}次请求失败,放弃此次请求。") continue return data_get_list @staticmethod def entry_data_delete(data: dict, max_retries: int = 20) -> dict: """ 删除单条数据 :param data: 应包含应用ID、表单ID、数据ID :param max_retries: 最大重试次数,默认20 :return: 删除结果 """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/delete' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data_id": data['data_id'], }) retries = 0 delete_status = None while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) delete_status = res.json() # 手动处理状态码 4001(数据不存在) if delete_status == { "code": 4001, "msg": "Data does not exist." }: logger.info(f"返回结果: {delete_status}") break res.raise_for_status() if res.status_code == 200: break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(10) if retries > max_retries: error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") continue return delete_status @staticmethod def entry_data_batch_delete( data: dict, chunk_size: int = 90, max_retries: int = 20 ) -> List[Optional[Dict]]: """ 批量删除数据 :param data: 应包含应用ID、表单ID、数据ID列表 :param chunk_size: 单次删除最大条数,默认90 :param max_retries: 重试次数,默认20 :return: 删除结果列表 """ data = replace_decimals(data) url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_delete' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } total_length = len(data['data_ids']) logger.info(f"多数据删除行数: {total_length}") num_chunks = (total_length + chunk_size - 1) // chunk_size data_get_list = [] for i in range(num_chunks): start_index = i * chunk_size end_index = min(start_index + chunk_size, total_length) payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "data_ids": data['data_ids'][start_index:end_index], }, cls=NpEncoder) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() logger.info(f"{i}页 返回结果: {data_get}") if data_get.get("status") == "success": data_get_list.append(data_get) break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(0.1) if retries > max_retries: error_logger.error( f"批量删除任务第{i+1}批 连续{max_retries}次请求失败,放弃此次请求。") data_get_list.append(None) return data_get_list @staticmethod def workflow_instance_get(data: dict, max_retries: int = 20) -> dict: """ 查询实例流程信息 :param max_retries: 最大重试次数 :param data: 简道云插件发送过来的data,包含应用id :return: 查询简道云流程实例信息返回的结果 """ url = 'https://api.jiandaoyun.com/api/v5/workflow/instance/get' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "instance_id": data['data_id'], "tasks_type": 1 }) data_get = None retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() data_get = res.json() if res.status_code == 200: break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(0.1) if retries > max_retries: error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。") return data_get @staticmethod def workflow_task_approve(data: dict, max_retries: int = 20) -> dict: """ 流程待办提交 :param max_retries: 最大重试次数 :param data: 应包含username、instance_id(data_id)、task_id等信息 :return: 返回简道云流程待办提交的结果 """ url = 'https://api.jiandaoyun.com/api/v1/workflow/task/approve' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "username": data["username"], "instance_id": data["instance_id"], "task_id": data['task_id'], "comment": data.get("comment", "自动转交") }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() if res.status_code == 200: return res.json() else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(3) if retries > max_retries: error_logger.error(f"任务 {data.get('task_id')} 连续{max_retries}次请求失败,放弃此次请求。") return {} @staticmethod def workflow_task_hand_over(data: dict, max_retries: int = 10) -> Optional[dict]: """ 流程待办转交 :param max_retries: 最大重试次数 :param data: 应包含username、instance_id(data_id)、task_id、transfer_username等信息 :return: 返回简道云流程待办转交的结果 """ url = 'https://api.jiandaoyun.com/api/v1/workflow/task/transfer' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "username": data["username"], "instance_id": data["instance_id"], "task_id": data['task_id'], "transfer_username": data['transfer_username'], "comment": data.get("comment", "转交") }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() if res.status_code == 200: return res.json() else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(3) if retries > max_retries: error_logger.error(f"任务转交失败,已重试{max_retries}次") return None @staticmethod def get_upload_token(data: dict, max_retries: int = 10) -> Optional[Dict[str, Any]]: """ 获取文件上传凭证 :param max_retries: 最大重试次数 :param data: 应包含应用ID、表单ID、事务ID :return: 返回upload_url、upload_token """ url = 'https://api.jiandaoyun.com/api/v5/app/entry/file/get_upload_token' headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, 'Content-Type': 'application/json' } payload = json.dumps({ "app_id": data['api_key'], "entry_id": data['entry_id'], "transaction_id": data['transaction_id'], }) retries = 0 while retries <= max_retries: try: res = requests.post(url=url, data=payload, headers=headers, timeout=10) res.raise_for_status() res_j = res.json() # 检查 token_and_url_list 是否存在且不为空 token_list = res_j.get('token_and_url_list', []) if not token_list or len(token_list) == 0: logger.warning(f"未获取到上传凭证列表,将重新请求") retries += 1 time.sleep(3) continue token_item = token_list[0] upload_url = token_item.get('url') upload_token = token_item.get('token') if not upload_url or not upload_token: logger.warning(f"上传凭证信息不完整,将重新请求") retries += 1 time.sleep(3) continue logger.info(f"返回结果: {upload_url}, {upload_token}") if res.status_code == 200: return { 'upload_url': upload_url, 'upload_token': upload_token } else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except (requests.exceptions.RequestException, KeyError, IndexError, TypeError) as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(3) if retries > max_retries: error_logger.error(f"获取上传凭证失败,已重试{max_retries}次") return None @staticmethod def upload_file(data: dict, max_retries: int = 10) -> Optional[Any]: """ 上传文件 :param max_retries: 最大重试次数 :param data: 应包含上传文件路径、上传文件url、上传文件token :return: 返回上传文件结果 """ url = data['upload_url'] headers = { 'Authorization': Config.JIANDAOYUN_API_TOKEN, } file_path = data['file_path'] payload = { "token": data['upload_token'], } retries = 0 result = None while retries <= max_retries: try: with open(file_path, 'rb') as f: files = {"file": f} res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10) res.raise_for_status() data_get = res.json() logger.info(f"返回结果: {data_get}") if res.status_code == 200: result = data_get break else: logger.warning(f"请求异常, 将重新请求") retries += 1 time.sleep(3) except requests.exceptions.RequestException as e: logger.warning(f"请求异常: {e}, 将重新请求") retries += 1 time.sleep(3) if retries > max_retries: error_logger.error(f"上传文件失败,已重试{max_retries}次") return result