feishu_client.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. # feishu_client.py
  2. import requests
  3. import json
  4. import logging
  5. import time
  6. class FeishuClient:
  7. """
  8. 封装飞书多维表格的API操作。
  9. 负责获取Access Token,以及对多维表格的读写操作。
  10. """
  11. def __init__(self, app_id: str, app_secret: str):
  12. self.app_id = app_id
  13. self.app_secret = app_secret
  14. self._access_token = None
  15. self._token_expires_at = 0 # Unix timestamp, 用于缓存Token过期时间
  16. def _get_access_token(self) -> str:
  17. """
  18. 获取飞书应用的 Access Token。
  19. 如果当前token未过期,则直接返回;否则,重新请求获取。
  20. Token有效期通常为2小时,这里会提前120秒刷新。
  21. """
  22. # 检查缓存的token是否仍然有效
  23. if self._access_token and self._token_expires_at > time.time():
  24. return self._access_token
  25. url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
  26. headers = {"Content-Type": "application/json; charset=utf-8"}
  27. payload = {
  28. "app_id": self.app_id,
  29. "app_secret": self.app_secret
  30. }
  31. try:
  32. logging.info("正在请求飞书 Access Token...")
  33. response = requests.post(url, headers=headers, json=payload, timeout=10)
  34. response.raise_for_status() # 检查HTTP响应状态码,如果不是2xx则抛出HTTPError
  35. data = response.json()
  36. if data.get("code") == 0:
  37. self._access_token = data.get("app_access_token")
  38. # 记录过期时间,提前2分钟刷新
  39. self._token_expires_at = time.time() + data.get("expire", 7200) - 120
  40. logging.info("飞书 Access Token 获取成功。")
  41. return self._access_token
  42. else:
  43. logging.error(f"获取飞书 Access Token 失败: {data.get('msg')} (Code: {data.get('code')})")
  44. raise Exception(f"Feishu Token Error: {data.get('msg')}")
  45. except requests.exceptions.RequestException as e:
  46. logging.error(f"请求飞书 Access Token 发生网络错误: {e}")
  47. raise Exception(f"Feishu Token Request Failed: {e}")
  48. except Exception as e:
  49. logging.error(f"处理飞书 Access Token 响应失败: {e}")
  50. raise Exception(f"Feishu Token Response Handling Failed: {e}")
  51. def read_records(self, base_id: str, table_id: str, field_names: list, page_size: int = 100) -> list:
  52. """
  53. 从飞书多维表格读取记录。
  54. Args:
  55. base_id (str): 多维表格的 Base ID (应用 Token)。
  56. table_id (str): 表的 Table ID。
  57. field_names (list): 要读取的字段名称列表。
  58. page_size (int): 每次请求的记录数 (最大500)。
  59. Returns:
  60. list: 包含记录字典的列表。
  61. Raises:
  62. Exception: 如果读取失败。
  63. """
  64. access_token = self._get_access_token()
  65. url = (
  66. f"https://open.feishu.cn/open-apis/bitable/v1/apps/{base_id}/tables/{table_id}/records"
  67. )
  68. headers = {
  69. "Authorization": f"Bearer {access_token}",
  70. "Content-Type": "application/json"
  71. }
  72. params = {
  73. "page_size": min(page_size, 500), # 飞书API page_size 最大500
  74. "field_names": json.dumps(field_names)
  75. }
  76. records = []
  77. try:
  78. logging.info(f"正在从飞书多维表格读取数据 (表: {table_id}, 请求字段: {field_names}, 每页: {params['page_size']})...")
  79. response = requests.get(url, headers=headers, params=params, timeout=30)
  80. response.raise_for_status()
  81. data = response.json()
  82. if data.get("code") == 0:
  83. records.extend(data.get("data", {}).get("items", []))
  84. # 注意:这里只处理了第一页数据。如果需要读取所有数据,您需要实现分页循环逻辑
  85. if data.get("data", {}).get("has_more"):
  86. logging.warning(f"飞书多维表格 (表: {table_id}) 存在更多数据未读取。当前实现仅获取了第一页。")
  87. logging.info(f"成功读取 {len(records)} 条记录。")
  88. return records
  89. else:
  90. logging.error(f"从飞书多维表格读取数据失败: {data.get('msg')} (Code: {data.get('code')})")
  91. raise Exception(f"Feishu Read Error: {data.get('msg')}")
  92. except requests.exceptions.RequestException as e:
  93. logging.error(f"请求飞书多维表格读取数据发生网络错误: {e}")
  94. raise Exception(f"Feishu Read Request Failed: {e}")
  95. except Exception as e:
  96. logging.error(f"处理飞书多维表格读取响应失败: {e}")
  97. raise Exception(f"Feishu Read Response Handling Failed: {e}")
  98. def update_records(self, base_id: str, table_id: str, records_data: list) -> bool:
  99. """
  100. 更新飞书多维表格中的记录。
  101. Args:
  102. base_id (str): 多维表格的 Base ID。
  103. table_id (str): 表的 Table ID。
  104. records_data (list): 包含要更新的记录字典的列表,每个字典需包含 record_id 和 fields。
  105. 例如: [{"record_id": "recxxxxxxxx", "fields": {"字段名": "新内容"}}]
  106. 每次批量更新最多支持500条记录。
  107. Returns:
  108. bool: True 如果更新成功,False 如果失败。
  109. Raises:
  110. Exception: 如果更新失败。
  111. """
  112. if not records_data:
  113. logging.info("没有要更新的记录,跳过写入飞书操作。")
  114. return True
  115. access_token = self._get_access_token()
  116. url = (
  117. f"https://open.feishu.cn/open-apis/bitable/v1/apps/{base_id}/tables/{table_id}/records"
  118. )
  119. headers = {
  120. "Authorization": f"Bearer {access_token}",
  121. "Content-Type": "application/json"
  122. }
  123. # 飞书批量更新API限制每批最多500条
  124. chunk_size = 500
  125. all_success = True
  126. for i in range(0, len(records_data), chunk_size):
  127. batch_records = records_data[i:i + chunk_size]
  128. payload = {
  129. "records": batch_records
  130. }
  131. try:
  132. logging.info(f"正在将 {len(batch_records)} 条记录分批写回飞书多维表格 (表: {table_id}, 批次: {i//chunk_size + 1})...")
  133. response = requests.post(url, headers=headers, json=payload, timeout=30)
  134. response.raise_for_status()
  135. data = response.json()
  136. if data.get("code") == 0:
  137. logging.info(f"批次 {i//chunk_size + 1} 成功写入/更新 {len(batch_records)} 条记录。")
  138. else:
  139. logging.error(f"将数据写入飞书多维表格失败 (批次 {i//chunk_size + 1}): {data.get('msg')} (Code: {data.get('code')})")
  140. all_success = False
  141. # 可以在这里选择抛出异常中断,或者继续处理下一批
  142. # raise Exception(f"Feishu Write Error in batch {i//chunk_size + 1}: {data.get('msg')}")
  143. except requests.exceptions.RequestException as e:
  144. logging.error(f"请求飞书多维表格写入数据发生网络错误 (批次 {i//chunk_size + 1}): {e}")
  145. all_success = False
  146. # raise Exception(f"Feishu Write Request Failed in batch {i//chunk_size + 1}: {e}")
  147. except Exception as e:
  148. logging.error(f"处理飞书多维表格写入响应失败 (批次 {i//chunk_size + 1}): {e}")
  149. all_success = False
  150. # raise Exception(f"Feishu Write Response Handling Failed in batch {i//chunk_size + 1}: {e}")
  151. return all_success
  152. # 可以在这里添加一些简单的测试代码,但通常在main.py中进行集成测试
  153. if __name__ == '__main__':
  154. # 仅作示例,请勿在生产环境直接硬编码敏感信息
  155. # 请替换为您的实际飞书应用信息
  156. # 配置日志,用于独立测试
  157. logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
  158. print("--- 飞书客户端独立测试 (请确保替换YOUR_...) ---")
  159. TEST_APP_ID = "YOUR_FEISHU_APP_ID"
  160. TEST_APP_SECRET = "YOUR_FEISHU_APP_SECRET"
  161. TEST_BASE_ID = "YOUR_FEISHU_BASE_ID"
  162. TEST_TABLE_ID = "YOUR_FEISHU_TABLE_ID"
  163. TEST_INPUT_FIELD = "测试文本" # 确保您的表中有此字段
  164. TEST_OUTPUT_FIELD = "测试输出" # 确保您的表中有此字段
  165. if "YOUR_" in TEST_APP_ID:
  166. logging.warning("请替换 feishu_client.py 中的 YOUR_ 占位符为您的实际飞书应用信息以运行测试。")
  167. else:
  168. try:
  169. feishu_client = FeishuClient(TEST_APP_ID, TEST_APP_SECRET)
  170. # 测试读取
  171. print("\n--- 测试读取记录 ---")
  172. read_records_data = feishu_client.read_records(TEST_BASE_ID, TEST_TABLE_ID, [TEST_INPUT_FIELD, TEST_OUTPUT_FIELD], page_size=2)
  173. for record in read_records_data:
  174. print(f"Record ID: {record.get('record_id')}, Fields: {record.get('fields')}")
  175. # 测试写入/更新 (仅更新第一条记录的某个字段)
  176. if read_records_data:
  177. print("\n--- 测试更新记录 ---")
  178. first_record_id = read_records_data[0]['record_id']
  179. update_payload = [{
  180. "record_id": first_record_id,
  181. "fields": {TEST_OUTPUT_FIELD: f"Updated by test at {time.strftime('%Y-%m-%d %H:%M:%S')}"}
  182. }]
  183. update_success = feishu_client.update_records(TEST_BASE_ID, TEST_TABLE_ID, update_payload)
  184. print(f"更新操作是否成功: {update_success}")
  185. else:
  186. print("\n没有可读取的记录,跳过更新测试。")
  187. except Exception as e:
  188. logging.error(f"飞书客户端独立测试失败: {e}")
  189. print("--- 飞书客户端独立测试结束 ---")