feat: 添加飞书tenant_access_token自动获取功能

- 在FeishuSheetsClient中添加_get_tenant_access_token()方法
- 实现token自动缓存和刷新机制(提前30分钟刷新)
- 更新配置类支持FEISHU_APP_ID和FEISHU_APP_SECRET
- 从.env中移除FEISHU_TOKEN,完全使用应用凭证
- 更新report.py和gui.py支持新的配置检查逻辑
- 更新FeishuScheduleManager配置检查逻辑
- 更新文档和示例文件说明新的配置方式

系统现在支持两种认证方式:
1. 推荐:使用应用凭证(FEISHU_APP_ID + FEISHU_APP_SECRET)
2. 备选:使用手动token(FEISHU_TOKEN)

所有功能测试通过,系统能自动获取、缓存和刷新token。
This commit is contained in:
2025-12-31 06:03:51 +08:00
parent 929c4b836f
commit 9b19015156
7 changed files with 298 additions and 25 deletions

View File

@@ -5,5 +5,12 @@ CONFLUENCE_CONTENT_ID=155764524
# 飞书表格配置 # 飞书表格配置
FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3 FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3
FEISHU_TOKEN=your-feishu-api-token
FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh
# 飞书应用凭证推荐方式自动获取tenant_access_token
# 创建飞书自建应用后获取app_id和app_secret
FEISHU_APP_ID=your-feishu-app-id
FEISHU_APP_SECRET=your-feishu-app-secret
# 备选手动配置token不推荐token会过期
# FEISHU_TOKEN=your-feishu-api-token

View File

@@ -75,9 +75,16 @@ CONFLUENCE_CONTENT_ID=155764524
# 飞书表格配置(用于获取排班人员信息) # 飞书表格配置(用于获取排班人员信息)
FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3 FEISHU_BASE_URL=https://open.feishu.cn/open-apis/sheets/v3
FEISHU_TOKEN=your-feishu-api-token
FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh FEISHU_SPREADSHEET_TOKEN=EgNPssi2ghZ7BLtGiTxcIBUmnVh
# 飞书应用凭证推荐方式自动获取tenant_access_token
# 创建飞书自建应用后获取app_id和app_secret
FEISHU_APP_ID=your-feishu-app-id
FEISHU_APP_SECRET=your-feishu-app-secret
# 备选手动配置token不推荐token会过期
# FEISHU_TOKEN=your-feishu-api-token
# 数据库配置 # 数据库配置
DATABASE_PATH=data/daily_logs.db DATABASE_PATH=data/daily_logs.db
@@ -205,10 +212,29 @@ GUI 功能:
- **`manager.py`** - 内容管理器,提供高级内容管理功能 - **`manager.py`** - 内容管理器,提供高级内容管理功能
### 飞书模块 (`src/feishu/`) ### 飞书模块 (`src/feishu/`)
- **`client.py`** - 飞书 API 客户端 - **`client.py`** - 飞书 API 客户端支持自动获取和刷新tenant_access_token
- **`parser.py`** - 排班数据解析器 - **`parser.py`** - 排班数据解析器
- **`manager.py`** - 飞书排班管理器,缓存和刷新排班信息 - **`manager.py`** - 飞书排班管理器,缓存和刷新排班信息
#### Token 自动获取机制
飞书模块现在支持两种认证方式:
1. **推荐方式**使用应用凭证FEISHU_APP_ID + FEISHU_APP_SECRET
- 系统会自动调用飞书API获取tenant_access_token
- token有效期2小时系统会在过期前30分钟自动刷新
- 无需手动管理token过期问题
2. **备选方式**使用手动配置的FEISHU_TOKEN
- 兼容旧配置方式
- token过期后需要手动更新
- 不推荐长期使用
#### 如何获取应用凭证
1. 登录飞书开放平台https://open.feishu.cn/
2. 创建自建应用
3. 在"凭证与基础信息"中获取App ID和App Secret
4. 为应用添加"获取tenant_access_token"权限
5. 将应用发布到企业(仅自建应用需要)
### 数据库模块 (`src/database/`) ### 数据库模块 (`src/database/`)
- **`base.py`** - 数据库基类,提供统一的连接管理 - **`base.py`** - 数据库基类,提供统一的连接管理
- **`daily_logs.py`** - 每日交接班日志数据库 - **`daily_logs.py`** - 每日交接班日志数据库
@@ -271,7 +297,15 @@ python3 main.py report-today
1. **连接失败**: 检查 `.env` 文件中的 API 令牌和 URL 1. **连接失败**: 检查 `.env` 文件中的 API 令牌和 URL
2. **数据库错误**: 确保 `data/` 目录存在且有写入权限 2. **数据库错误**: 确保 `data/` 目录存在且有写入权限
3. **解析错误**: 检查 Confluence 页面结构是否发生变化 3. **解析错误**: 检查 Confluence 页面结构是否发生变化
4. **飞书数据获取失败**: 验证飞书表格权限和 token 有效性 4. **飞书数据获取失败**:
- 验证飞书表格权限
- 检查应用凭证是否正确FEISHU_APP_ID + FEISHU_APP_SECRET
- 确认应用已发布到企业(自建应用需要)
- 检查网络连接是否能访问飞书API
5. **飞书token获取失败**:
- 确认应用有"获取tenant_access_token"权限
- 检查app_id和app_secret是否正确
- 查看日志文件获取详细错误信息
### 日志级别 ### 日志级别

View File

@@ -23,6 +23,8 @@ class Config:
FEISHU_BASE_URL = os.getenv('FEISHU_BASE_URL', 'https://open.feishu.cn/open-apis/sheets/v3') FEISHU_BASE_URL = os.getenv('FEISHU_BASE_URL', 'https://open.feishu.cn/open-apis/sheets/v3')
FEISHU_TOKEN = os.getenv('FEISHU_TOKEN') FEISHU_TOKEN = os.getenv('FEISHU_TOKEN')
FEISHU_SPREADSHEET_TOKEN = os.getenv('FEISHU_SPREADSHEET_TOKEN') FEISHU_SPREADSHEET_TOKEN = os.getenv('FEISHU_SPREADSHEET_TOKEN')
FEISHU_APP_ID = os.getenv('FEISHU_APP_ID')
FEISHU_APP_SECRET = os.getenv('FEISHU_APP_SECRET')
# 数据库配置 # 数据库配置
DATABASE_PATH = os.getenv('DATABASE_PATH', 'data/daily_logs.db') DATABASE_PATH = os.getenv('DATABASE_PATH', 'data/daily_logs.db')
@@ -70,8 +72,17 @@ class Config:
errors.append("CONFLUENCE_CONTENT_ID 未配置") errors.append("CONFLUENCE_CONTENT_ID 未配置")
# 检查飞书配置(可选,但建议配置) # 检查飞书配置(可选,但建议配置)
if not cls.FEISHU_TOKEN: has_feishu_token = bool(cls.FEISHU_TOKEN)
print("警告: FEISHU_TOKEN 未配置,排班功能将不可用") has_app_credentials = bool(cls.FEISHU_APP_ID and cls.FEISHU_APP_SECRET)
if not has_feishu_token and not has_app_credentials:
print("警告: 飞书认证未配置,排班功能将不可用")
print(" 请配置 FEISHU_TOKEN 或 FEISHU_APP_ID + FEISHU_APP_SECRET")
elif has_app_credentials:
print("信息: 使用飞书应用凭证自动获取token")
elif has_feishu_token:
print("信息: 使用手动配置的FEISHU_TOKEN")
if not cls.FEISHU_SPREADSHEET_TOKEN: if not cls.FEISHU_SPREADSHEET_TOKEN:
print("警告: FEISHU_SPREADSHEET_TOKEN 未配置,排班功能将不可用") print("警告: FEISHU_SPREADSHEET_TOKEN 未配置,排班功能将不可用")
@@ -88,7 +99,21 @@ class Config:
"""打印配置摘要""" """打印配置摘要"""
print("配置摘要:") print("配置摘要:")
print(f" Confluence: {'已配置' if cls.CONFLUENCE_BASE_URL else '未配置'}") print(f" Confluence: {'已配置' if cls.CONFLUENCE_BASE_URL else '未配置'}")
print(f" 飞书: {'已配置' if cls.FEISHU_TOKEN else '未配置'}")
# 飞书配置详情
has_feishu_token = bool(cls.FEISHU_TOKEN)
has_app_credentials = bool(cls.FEISHU_APP_ID and cls.FEISHU_APP_SECRET)
has_spreadsheet_token = bool(cls.FEISHU_SPREADSHEET_TOKEN)
if has_app_credentials:
feishu_status = f"应用凭证 (ID: {cls.FEISHU_APP_ID[:8]}...)"
elif has_feishu_token:
feishu_status = "手动token"
else:
feishu_status = "未配置"
print(f" 飞书认证: {feishu_status}")
print(f" 飞书表格: {'已配置' if has_spreadsheet_token else '未配置'}")
print(f" 数据库路径: {cls.DATABASE_PATH}") print(f" 数据库路径: {cls.DATABASE_PATH}")
print(f" 每日目标TEU: {cls.DAILY_TARGET_TEU}") print(f" 每日目标TEU: {cls.DAILY_TARGET_TEU}")
print(f" 排班刷新天数: {cls.SCHEDULE_REFRESH_DAYS}") print(f" 排班刷新天数: {cls.SCHEDULE_REFRESH_DAYS}")

View File

@@ -2,9 +2,11 @@
""" """
飞书表格 API 客户端模块 飞书表格 API 客户端模块
统一版本,支持月度表格和年度表格 统一版本,支持月度表格和年度表格
支持自动获取和刷新 tenant_access_token
""" """
import requests import requests
from typing import Dict, List, Optional import time
from typing import Dict, List, Optional, Tuple
import logging import logging
from src.config import config from src.config import config
@@ -21,32 +23,147 @@ class FeishuClientError(Exception):
class FeishuSheetsClient: class FeishuSheetsClient:
"""飞书表格 API 客户端""" """飞书表格 API 客户端"""
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None, def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None,
spreadsheet_token: Optional[str] = None): spreadsheet_token: Optional[str] = None, app_id: Optional[str] = None,
app_secret: Optional[str] = None):
""" """
初始化客户端 初始化客户端
参数: 参数:
base_url: 飞书 API 基础URL如果为None则使用配置 base_url: 飞书 API 基础URL如果为None则使用配置
token: Bearer 认证令牌如果为None则使用配置 token: Bearer 认证令牌如果为None则使用配置或自动获取
spreadsheet_token: 表格 token如果为None则使用配置 spreadsheet_token: 表格 token如果为None则使用配置
app_id: 飞书应用ID用于获取tenant_access_token
app_secret: 飞书应用密钥用于获取tenant_access_token
""" """
self.base_url = (base_url or config.FEISHU_BASE_URL).rstrip('/') self.base_url = (base_url or config.FEISHU_BASE_URL).rstrip('/')
self.spreadsheet_token = spreadsheet_token or config.FEISHU_SPREADSHEET_TOKEN self.spreadsheet_token = spreadsheet_token or config.FEISHU_SPREADSHEET_TOKEN
self.token = token or config.FEISHU_TOKEN self.app_id = app_id or config.FEISHU_APP_ID
self.app_secret = app_secret or config.FEISHU_APP_SECRET
self.headers = { # Token管理相关属性
'Authorization': f'Bearer {self.token}', self._token = token or config.FEISHU_TOKEN
'Content-Type': 'application/json', self._token_expire_time = 0 # token过期时间戳
'Accept': 'application/json' self._token_obtained_time = 0 # token获取时间戳
}
# 使用 Session 重用连接 # 使用 Session 重用连接
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update(self.headers)
self.session.timeout = config.REQUEST_TIMEOUT self.session.timeout = config.REQUEST_TIMEOUT
# 初始化headers
self._update_session_headers()
logger.debug(f"飞书客户端初始化完成基础URL: {self.base_url}") 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=config.REQUEST_TIMEOUT)
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 config.FEISHU_TOKEN and self._token == config.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 config.FEISHU_TOKEN and config.FEISHU_TOKEN != self._token:
logger.warning("使用备用FEISHU_TOKEN")
self._token = config.FEISHU_TOKEN
self._update_session_headers()
return True
return False
def get_sheets_info(self) -> List[Dict[str, str]]: def get_sheets_info(self) -> List[Dict[str, str]]:
""" """
@@ -59,6 +176,10 @@ class FeishuSheetsClient:
requests.exceptions.RequestException: 网络请求失败 requests.exceptions.RequestException: 网络请求失败
ValueError: API返回错误 ValueError: API返回错误
""" """
# 确保token有效
if not self._ensure_valid_token():
raise FeishuClientError("无法获取有效的飞书token")
url = f'{self.base_url}/spreadsheets/{self.spreadsheet_token}/sheets/query' url = f'{self.base_url}/spreadsheets/{self.spreadsheet_token}/sheets/query'
try: try:
@@ -104,6 +225,10 @@ class FeishuSheetsClient:
requests.exceptions.RequestException: 网络请求失败 requests.exceptions.RequestException: 网络请求失败
ValueError: API返回错误 ValueError: API返回错误
""" """
# 确保token有效
if not self._ensure_valid_token():
raise FeishuClientError("无法获取有效的飞书token")
if range_ is None: if range_ is None:
range_ = config.SHEET_RANGE range_ = config.SHEET_RANGE
@@ -134,6 +259,26 @@ class FeishuSheetsClient:
logger.error(f"解析表格数据失败: {e}, sheet_id: {sheet_id}") logger.error(f"解析表格数据失败: {e}, sheet_id: {sheet_id}")
raise 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(config.FEISHU_TOKEN and self._token == config.FEISHU_TOKEN)
}
def test_connection(self) -> bool: def test_connection(self) -> bool:
""" """
测试飞书连接是否正常 测试飞书连接是否正常
@@ -142,6 +287,12 @@ class FeishuSheetsClient:
连接是否正常 连接是否正常
""" """
try: try:
# 首先测试token获取
if not self._ensure_valid_token():
logger.error("无法获取有效的飞书token")
return False
# 然后测试表格访问
sheets = self.get_sheets_info() sheets = self.get_sheets_info()
if sheets: if sheets:
logger.info(f"飞书连接测试成功,找到 {len(sheets)} 个表格") logger.info(f"飞书连接测试成功,找到 {len(sheets)} 个表格")
@@ -152,6 +303,27 @@ class FeishuSheetsClient:
except Exception as e: except Exception as e:
logger.error(f"飞书连接测试失败: {e}") logger.error(f"飞书连接测试失败: {e}")
return False 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__': if __name__ == '__main__':
@@ -164,8 +336,17 @@ if __name__ == '__main__':
# 测试连接 # 测试连接
client = FeishuSheetsClient() 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(): if client.test_connection():
print("飞书连接测试成功") print("\n飞书连接测试成功")
# 获取表格信息 # 获取表格信息
sheets = client.get_sheets_info() sheets = client.get_sheets_info()
@@ -177,6 +358,10 @@ if __name__ == '__main__':
sheet_id = sheets[0]['sheet_id'] sheet_id = sheets[0]['sheet_id']
data = client.get_sheet_data(sheet_id, 'A1:C5') data = client.get_sheet_data(sheet_id, 'A1:C5')
print(f"获取到表格数据,版本: {data.get('revision', '未知')}") print(f"获取到表格数据,版本: {data.get('revision', '未知')}")
# 再次显示token信息
token_info = client.get_token_info()
print(f"\n测试后token剩余时间: {int(token_info['time_remaining'])}")
else: else:
print("飞书连接测试失败") print("\n飞书连接测试失败")
sys.exit(1) sys.exit(1)

View File

@@ -42,8 +42,17 @@ class FeishuScheduleManager:
def _check_config(self, token: Optional[str], spreadsheet_token: Optional[str]) -> None: def _check_config(self, token: Optional[str], spreadsheet_token: Optional[str]) -> None:
"""检查必要配置""" """检查必要配置"""
if not token and not config.FEISHU_TOKEN: # 检查是否有任何可用的认证方式
logger.warning("飞书令牌未配置,排班功能将不可用") has_token = bool(token or config.FEISHU_TOKEN)
has_app_credentials = bool(config.FEISHU_APP_ID and config.FEISHU_APP_SECRET)
if not has_token and not has_app_credentials:
logger.warning("飞书认证未配置,排班功能将不可用")
logger.warning("请配置 FEISHU_TOKEN 或 FEISHU_APP_ID + FEISHU_APP_SECRET")
elif has_app_credentials:
logger.info("使用飞书应用凭证自动获取token")
elif has_token:
logger.info("使用手动配置的FEISHU_TOKEN")
if not spreadsheet_token and not config.FEISHU_SPREADSHEET_TOKEN: if not spreadsheet_token and not config.FEISHU_SPREADSHEET_TOKEN:
logger.warning("飞书表格令牌未配置,排班功能将不可用") logger.warning("飞书表格令牌未配置,排班功能将不可用")

View File

@@ -487,7 +487,13 @@ class OrbitInGUI:
try: try:
# 1. 检查飞书配置,如果配置完整则刷新排班信息 # 1. 检查飞书配置,如果配置完整则刷新排班信息
if config.FEISHU_TOKEN and config.FEISHU_SPREADSHEET_TOKEN: # 支持应用凭证和手动token两种方式
has_feishu_config = bool(config.FEISHU_SPREADSHEET_TOKEN) and (
bool(config.FEISHU_APP_ID and config.FEISHU_APP_SECRET) or
bool(config.FEISHU_TOKEN)
)
if has_feishu_config:
try: try:
self.log_message("正在刷新排班信息...") self.log_message("正在刷新排班信息...")
self.logger.info("正在刷新排班信息...") self.logger.info("正在刷新排班信息...")
@@ -507,6 +513,7 @@ class OrbitInGUI:
else: else:
self.log_message("飞书配置不完整,跳过排班信息刷新") self.log_message("飞书配置不完整,跳过排班信息刷新")
self.logger.warning("飞书配置不完整,跳过排班信息刷新") self.logger.warning("飞书配置不完整,跳过排班信息刷新")
self.logger.warning("需要配置 FEISHU_SPREADSHEET_TOKEN 和 (FEISHU_APP_ID+FEISHU_APP_SECRET 或 FEISHU_TOKEN)")
# 2. 尝试获取最新的作业数据 # 2. 尝试获取最新的作业数据
self.log_message("正在尝试获取最新作业数据...") self.log_message("正在尝试获取最新作业数据...")

View File

@@ -181,9 +181,15 @@ class DailyReportGenerator:
班次人员字典 班次人员字典
""" """
try: try:
# 检查飞书配置 # 检查飞书配置支持应用凭证和手动token两种方式
if not config.FEISHU_TOKEN or not config.FEISHU_SPREADSHEET_TOKEN: has_feishu_config = bool(config.FEISHU_SPREADSHEET_TOKEN) and (
bool(config.FEISHU_APP_ID and config.FEISHU_APP_SECRET) or
bool(config.FEISHU_TOKEN)
)
if not has_feishu_config:
logger.warning("飞书配置不完整,跳过排班信息获取") logger.warning("飞书配置不完整,跳过排班信息获取")
logger.warning("需要配置 FEISHU_SPREADSHEET_TOKEN 和 (FEISHU_APP_ID+FEISHU_APP_SECRET 或 FEISHU_TOKEN)")
return self._empty_personnel() return self._empty_personnel()
# 初始化飞书排班管理器 # 初始化飞书排班管理器