feat: 初始化福州港日报管理系统
- 添加日报生成功能 (report_generator.py) - 添加 GUI 界面 (daily_report_gui.py) - 添加班次交接报告功能 (shift_report.py) - 集成飞书 API 获取排班信息 - 集成 Metabase 查询作业数据 - 生成 AGENTS.md 文档
This commit is contained in:
394
feishu/client.py
Normal file
394
feishu/client.py
Normal file
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书表格 API 客户端模块
|
||||
统一版本,支持月度表格和年度表格
|
||||
支持自动获取和刷新 tenant_access_token
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import logging
|
||||
|
||||
# 加载环境变量
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeishuClientError(Exception):
|
||||
"""飞书客户端异常基类"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class FeishuSheetsClient:
|
||||
"""飞书表格 API 客户端"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
spreadsheet_token: Optional[str] = None,
|
||||
app_id: Optional[str] = None,
|
||||
app_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
初始化客户端
|
||||
|
||||
参数:
|
||||
base_url: 飞书 API 基础URL,如果为None则使用配置
|
||||
token: Bearer 认证令牌,如果为None则使用配置或自动获取
|
||||
spreadsheet_token: 表格 token,如果为None则使用配置
|
||||
app_id: 飞书应用ID,用于获取tenant_access_token
|
||||
app_secret: 飞书应用密钥,用于获取tenant_access_token
|
||||
"""
|
||||
self.base_url = (base_url or os.getenv("FEISHU_BASE_URL")).rstrip("/")
|
||||
self.spreadsheet_token = spreadsheet_token or os.getenv(
|
||||
"FEISHU_SPREADSHEET_TOKEN"
|
||||
)
|
||||
self.app_id = app_id or os.getenv("FEISHU_APP_ID")
|
||||
self.app_secret = app_secret or os.getenv("FEISHU_APP_SECRET")
|
||||
|
||||
# Token管理相关属性
|
||||
self._token = token or os.getenv("FEISHU_TOKEN")
|
||||
self._token_expire_time = 0 # token过期时间戳
|
||||
self._token_obtained_time = 0 # token获取时间戳
|
||||
|
||||
# 使用 Session 重用连接
|
||||
self.session = requests.Session()
|
||||
self.session.timeout = int(os.getenv("REQUEST_TIMEOUT", 30))
|
||||
|
||||
# 初始化headers
|
||||
self._update_session_headers()
|
||||
|
||||
logger.debug(f"飞书客户端初始化完成,基础URL: {self.base_url}")
|
||||
logger.debug(
|
||||
f"使用应用ID: {self.app_id[:8]}... 如果配置"
|
||||
if self.app_id
|
||||
else "未配置应用ID"
|
||||
)
|
||||
|
||||
def _update_session_headers(self):
|
||||
"""更新session的headers"""
|
||||
if self._token:
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 如果没有token,移除Authorization头
|
||||
if "Authorization" in self.session.headers:
|
||||
del self.session.headers["Authorization"]
|
||||
|
||||
def _get_tenant_access_token(self) -> Tuple[str, int]:
|
||||
"""
|
||||
获取tenant_access_token
|
||||
|
||||
返回:
|
||||
(token, expire_time): token字符串和过期时间(秒)
|
||||
|
||||
异常:
|
||||
requests.exceptions.RequestException: 网络请求失败
|
||||
ValueError: API返回错误
|
||||
"""
|
||||
if not self.app_id or not self.app_secret:
|
||||
raise ValueError("未配置飞书应用ID和密钥,无法获取tenant_access_token")
|
||||
|
||||
token_url = (
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
)
|
||||
|
||||
payload = {"app_id": self.app_id, "app_secret": self.app_secret}
|
||||
|
||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
|
||||
try:
|
||||
logger.info(f"正在获取tenant_access_token,应用ID: {self.app_id[:8]}...")
|
||||
response = requests.post(
|
||||
token_url, json=payload, headers=headers, timeout=int(os.getenv("REQUEST_TIMEOUT", 30))
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
error_msg = f"获取tenant_access_token失败: {data.get('msg')}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
token = data.get("tenant_access_token")
|
||||
expire = data.get("expire", 7200) # 默认2小时
|
||||
|
||||
if not token:
|
||||
raise ValueError("API返回的token为空")
|
||||
|
||||
logger.info(f"成功获取tenant_access_token,有效期: {expire}秒")
|
||||
return token, expire
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取tenant_access_token网络请求失败: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取tenant_access_token失败: {e}")
|
||||
raise
|
||||
|
||||
def _ensure_valid_token(self):
|
||||
"""
|
||||
确保当前token有效,如果无效则重新获取
|
||||
|
||||
返回:
|
||||
bool: token是否有效
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果有手动配置的token,直接使用
|
||||
if os.getenv("FEISHU_TOKEN") and self._token == os.getenv("FEISHU_TOKEN"):
|
||||
logger.debug("使用手动配置的FEISHU_TOKEN")
|
||||
return True
|
||||
|
||||
# 检查token是否过期(提前30分钟刷新)
|
||||
if self._token and self._token_expire_time > 0:
|
||||
time_remaining = self._token_expire_time - current_time
|
||||
if time_remaining > 1800: # 剩余时间大于30分钟
|
||||
logger.debug(f"token仍然有效,剩余时间: {int(time_remaining)}秒")
|
||||
return True
|
||||
elif time_remaining > 0: # 剩余时间小于30分钟但大于0
|
||||
logger.info(
|
||||
f"token即将过期,剩余时间: {int(time_remaining)}秒,重新获取"
|
||||
)
|
||||
else: # 已过期
|
||||
logger.info("token已过期,重新获取")
|
||||
|
||||
# 需要获取新token
|
||||
try:
|
||||
token, expire = self._get_tenant_access_token()
|
||||
self._token = token
|
||||
self._token_obtained_time = current_time
|
||||
self._token_expire_time = current_time + expire
|
||||
self._update_session_headers()
|
||||
logger.info(f"token获取成功,将在 {expire} 秒后过期")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"获取token失败: {e}")
|
||||
# 如果配置了备用token,尝试使用
|
||||
if os.getenv("FEISHU_TOKEN") and os.getenv("FEISHU_TOKEN") != self._token:
|
||||
logger.warning("使用备用FEISHU_TOKEN")
|
||||
self._token = os.getenv("FEISHU_TOKEN")
|
||||
self._update_session_headers()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_sheets_info(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
获取所有表格信息(sheet_id 和 title)
|
||||
|
||||
返回:
|
||||
表格信息列表 [{'sheet_id': 'xxx', 'title': 'xxx'}, ...]
|
||||
|
||||
异常:
|
||||
requests.exceptions.RequestException: 网络请求失败
|
||||
ValueError: API返回错误
|
||||
"""
|
||||
# 确保token有效
|
||||
if not self._ensure_valid_token():
|
||||
raise FeishuClientError("无法获取有效的飞书token")
|
||||
|
||||
url = f"{self.base_url}/spreadsheets/{self.spreadsheet_token}/sheets/query"
|
||||
|
||||
try:
|
||||
response = self.session.get(url, timeout=int(os.getenv("REQUEST_TIMEOUT", 30)))
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
error_msg = f"飞书API错误: {data.get('msg')}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
sheets = data.get("data", {}).get("sheets", [])
|
||||
result = []
|
||||
for sheet in sheets:
|
||||
result.append(
|
||||
{"sheet_id": sheet.get("sheet_id"), "title": sheet.get("title")}
|
||||
)
|
||||
|
||||
logger.info(f"获取到 {len(result)} 个表格")
|
||||
return result
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取表格信息失败: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"解析表格信息失败: {e}")
|
||||
raise
|
||||
|
||||
def get_sheet_data(self, sheet_id: str, range_: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
获取指定表格的数据
|
||||
|
||||
参数:
|
||||
sheet_id: 表格ID
|
||||
range_: 数据范围,如果为None则使用配置
|
||||
|
||||
返回:
|
||||
飞书API返回的原始数据,包含revision版本号
|
||||
|
||||
异常:
|
||||
requests.exceptions.RequestException: 网络请求失败
|
||||
ValueError: API返回错误
|
||||
"""
|
||||
# 确保token有效
|
||||
if not self._ensure_valid_token():
|
||||
raise FeishuClientError("无法获取有效的飞书token")
|
||||
|
||||
if range_ is None:
|
||||
range_ = os.getenv("SHEET_RANGE", "A1:Z100")
|
||||
|
||||
# 注意:获取表格数据使用 v2 API,而不是 v3
|
||||
url = f"{self.base_url.replace('/v3', '/v2')}/spreadsheets/{self.spreadsheet_token}/values/{sheet_id}!{range_}"
|
||||
params = {
|
||||
"valueRenderOption": "ToString",
|
||||
"dateTimeRenderOption": "FormattedString",
|
||||
}
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
url, params=params, timeout=int(os.getenv("REQUEST_TIMEOUT", 30))
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
error_msg = f"飞书API错误: {data.get('msg')}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.debug(f"获取表格数据成功: {sheet_id}, 范围: {range_}")
|
||||
return data.get("data", {})
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取表格数据失败: {e}, sheet_id: {sheet_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"解析表格数据失败: {e}, sheet_id: {sheet_id}")
|
||||
raise
|
||||
|
||||
def get_token_info(self) -> Dict[str, any]:
|
||||
"""
|
||||
获取当前token信息
|
||||
|
||||
返回:
|
||||
token信息字典
|
||||
"""
|
||||
current_time = time.time()
|
||||
time_remaining = (
|
||||
self._token_expire_time - current_time if self._token_expire_time > 0 else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"has_token": bool(self._token),
|
||||
"token_preview": self._token[:20] + "..."
|
||||
if self._token and len(self._token) > 20
|
||||
else self._token,
|
||||
"token_obtained_time": self._token_obtained_time,
|
||||
"token_expire_time": self._token_expire_time,
|
||||
"time_remaining": max(0, time_remaining),
|
||||
"using_app_credentials": bool(self.app_id and self.app_secret),
|
||||
"using_manual_token": bool(
|
||||
os.getenv("FEISHU_TOKEN") and self._token == os.getenv("FEISHU_TOKEN")
|
||||
),
|
||||
}
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""
|
||||
测试飞书连接是否正常
|
||||
|
||||
返回:
|
||||
连接是否正常
|
||||
"""
|
||||
try:
|
||||
# 首先测试token获取
|
||||
if not self._ensure_valid_token():
|
||||
logger.error("无法获取有效的飞书token")
|
||||
return False
|
||||
|
||||
# 然后测试表格访问
|
||||
sheets = self.get_sheets_info()
|
||||
if sheets:
|
||||
logger.info(f"飞书连接测试成功,找到 {len(sheets)} 个表格")
|
||||
return True
|
||||
else:
|
||||
logger.warning("飞书连接测试成功,但未找到表格")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"飞书连接测试失败: {e}")
|
||||
return False
|
||||
|
||||
def refresh_token(self) -> bool:
|
||||
"""
|
||||
强制刷新token
|
||||
|
||||
返回:
|
||||
刷新是否成功
|
||||
"""
|
||||
try:
|
||||
logger.info("强制刷新token...")
|
||||
current_time = time.time()
|
||||
token, expire = self._get_tenant_access_token()
|
||||
self._token = token
|
||||
self._token_obtained_time = current_time
|
||||
self._token_expire_time = current_time + expire
|
||||
self._update_session_headers()
|
||||
logger.info(f"token刷新成功,将在 {expire} 秒后过期")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"强制刷新token失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 测试代码
|
||||
import sys
|
||||
|
||||
# 设置日志级别
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# 测试连接
|
||||
client = FeishuSheetsClient()
|
||||
|
||||
# 显示token信息
|
||||
token_info = client.get_token_info()
|
||||
print("当前token信息:")
|
||||
print(f" 是否有token: {token_info['has_token']}")
|
||||
print(f" token预览: {token_info['token_preview']}")
|
||||
print(f" 剩余时间: {int(token_info['time_remaining'])}秒")
|
||||
print(f" 使用应用凭证: {token_info['using_app_credentials']}")
|
||||
print(f" 使用手动token: {token_info['using_manual_token']}")
|
||||
|
||||
if client.test_connection():
|
||||
print("\n飞书连接测试成功")
|
||||
|
||||
# 获取表格信息
|
||||
sheets = client.get_sheets_info()
|
||||
for sheet in sheets[:3]: # 只显示前3个
|
||||
print(f"表格: {sheet['title']} (ID: {sheet['sheet_id']})")
|
||||
|
||||
if sheets:
|
||||
# 获取第一个表格的数据
|
||||
sheet_id = sheets[0]["sheet_id"]
|
||||
data = client.get_sheet_data(sheet_id, "A1:C5")
|
||||
print(f"获取到表格数据,版本: {data.get('revision', '未知')}")
|
||||
|
||||
# 再次显示token信息
|
||||
token_info = client.get_token_info()
|
||||
print(f"\n测试后token剩余时间: {int(token_info['time_remaining'])}秒")
|
||||
else:
|
||||
print("\n飞书连接测试失败")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user