G41_TAPD_BUG_SYNC/src/tapd_api.py

665 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
TAPD API调用模块
负责与TAPD Open API交互创建和管理bug单
"""
import os
import requests
from typing import Dict, Optional, Any, Tuple, List
from requests.auth import HTTPBasicAuth
from src.api_logger import get_logger
class RateLimitError(RuntimeError):
"""TAPD 触发限速时抛出的专用异常"""
def __init__(self, message: str, endpoint: str = "", retry_after: str = None, status_code: int = 429):
super().__init__(message)
self.endpoint = endpoint
self.retry_after = retry_after
self.status_code = status_code
class TAPDApi:
"""TAPD API封装类"""
# TAPD API基础URL
BASE_URL = "https://tapd-api.bilibili.co/tapd"
def __init__(self, workspace_id: str, test_mode: bool = False):
"""
初始化TAPD API
Args:
workspace_id: TAPD项目ID
test_mode: 是否启用测试模式显示API请求和响应
Raises:
ValueError: 环境变量未设置时抛出
"""
self.workspace_id = workspace_id
self.test_mode = test_mode
self.session = requests.Session()
# 从环境变量读取认证信息
self.api_user = os.environ.get('TAPD_API_USER')
self.api_password = os.environ.get('TAPD_API_PASSWORD')
if not self.api_user or not self.api_password:
raise ValueError(
"TAPD认证信息未设置。请设置环境变量:\n"
" - TAPD_API_USER\n"
" - TAPD_API_PASSWORD"
)
# 设置Basic Auth
self.auth = HTTPBasicAuth(self.api_user, self.api_password)
# 初始化日志记录器
self.logger = get_logger()
print(f" ✓ TAPD API初始化完成 (workspace_id: {workspace_id})")
if test_mode:
print(f" ⚠ 测试模式已启用将显示所有TAPD API调用的详细信息")
@staticmethod
def _looks_like_rate_limit(error_msg: str) -> bool:
"""识别 TAPD 以业务错误形式返回的限速信息"""
normalized_msg = str(error_msg or "").lower()
rate_limit_keywords = [
"429",
"too many requests",
"rate limit",
"ratelimit",
"限速",
"频繁",
"请求过多",
]
return any(keyword in normalized_msg for keyword in rate_limit_keywords)
def _make_request(self, endpoint: str, method: str = "POST",
data: Optional[Dict] = None, params: Optional[Dict] = None) -> Dict:
"""
发起TAPD API请求的通用方法
Args:
endpoint: API端点"bugs"
method: HTTP方法GET或POST
data: POST请求的表单数据
params: URL查询参数
Returns:
Dict: API响应数据
Raises:
RuntimeError: API调用失败时抛出
"""
url = f"{self.BASE_URL}/{endpoint}"
# 准备日志记录的请求数据(隐藏认证信息)
log_request_data = {
"url": url,
"method": method,
"params": params,
"data": data,
"auth_user": self.api_user
}
# 测试模式:显示请求信息
if self.test_mode:
print("\n" + "=" * 80)
print(f"【测试模式】TAPD API调用: {endpoint}")
print("=" * 80)
print(f"请求方法: {method}")
print(f"请求URL: {url}")
print(f"认证用户: {self.api_user}")
if params:
print(f"URL参数:")
for key, value in params.items():
print(f" {key}: {value}")
if data:
print(f"表单数据:")
for key, value in data.items():
# 对于过长的内容只显示前100个字符
display_value = str(value)
if len(display_value) > 100:
display_value = display_value[:100] + "...(已截断)"
print(f" {key}: {display_value}")
try:
if method.upper() == "POST":
response = self.session.post(
url,
data=data,
params=params,
auth=self.auth,
timeout=30
)
else:
response = self.session.get(
url,
params=params,
auth=self.auth,
timeout=30
)
# 测试模式:显示响应信息
if self.test_mode:
print(f"\n响应状态码: {response.status_code}")
print(f"响应头:")
for key, value in response.headers.items():
if key.lower() in ['content-type', 'content-length']:
print(f" {key}: {value}")
try:
result = response.json()
print(f"响应数据:")
import json
print(json.dumps(result, ensure_ascii=False, indent=2))
except:
print(f"响应内容非JSON:")
print(response.text[:500])
print("=" * 80)
response.raise_for_status()
result = response.json()
# 检查TAPD API返回的状态
if result.get('status') != 1:
error_msg = result.get('info', '未知错误')
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data=result,
success=False,
error_message=error_msg
)
if self._looks_like_rate_limit(error_msg):
raise RateLimitError(
f"TAPD API触发限速: {error_msg}",
endpoint=endpoint,
status_code=429
)
raise RuntimeError(f"TAPD API调用失败: {error_msg}")
# 记录API调用日志成功
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data=result,
success=True
)
return result
except requests.exceptions.Timeout:
error_msg = f"TAPD API请求超时: {endpoint}"
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
raise RuntimeError(error_msg)
except requests.exceptions.HTTPError as e:
status_code = getattr(e.response, 'status_code', None)
retry_after = None
response_text = ""
if e.response is not None:
retry_after = e.response.headers.get("Retry-After")
response_text = e.response.text[:200]
if status_code == 429:
error_msg = f"TAPD API触发429限速: {endpoint}"
if retry_after:
error_msg += f",建议等待 {retry_after} 秒后重试"
if response_text:
error_msg += f"\n响应内容: {response_text}"
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={
"status_code": status_code,
"retry_after": retry_after,
"body": response_text
},
success=False,
error_message=error_msg
)
raise RateLimitError(
error_msg,
endpoint=endpoint,
retry_after=retry_after,
status_code=status_code
)
error_msg = f"TAPD API HTTP错误: {e}"
if response_text:
error_msg += f"\n响应内容: {response_text}"
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
raise RuntimeError(error_msg)
except requests.exceptions.RequestException as e:
error_msg = f"TAPD API请求失败: {e}"
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
raise RuntimeError(error_msg)
except ValueError as e:
error_msg = f"TAPD API响应解析失败: {e}"
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
raise RuntimeError(error_msg)
def create_bug(self, bug_data: Dict[str, Any]) -> Dict:
"""
创建TAPD bug单
Args:
bug_data: bug数据字典包含title、description等字段
Returns:
Dict: 创建成功的bug信息包含bug_id等
Raises:
RuntimeError: 创建失败时抛出
"""
# 添加workspace_id到请求数据
request_data = {
'workspace_id': self.workspace_id,
**bug_data
}
result = self._make_request("bugs", method="POST", data=request_data)
# TAPD API返回格式可能是:
# 1. {"status": 1, "data": {"Bug": {...}}} (直接返回Bug对象)
# 2. {"status": 1, "data": [{"Bug": {...}}]} (返回列表)
data = result.get('data')
if isinstance(data, dict) and 'Bug' in data:
# 格式1: 直接返回Bug对象
bug_info = data['Bug']
elif isinstance(data, list) and len(data) > 0:
# 格式2: 返回列表
first_item = data[0]
if isinstance(first_item, dict) and 'Bug' in first_item:
bug_info = first_item['Bug']
else:
raise RuntimeError(f"API返回数据格式异常: {first_item}")
else:
raise RuntimeError("API返回数据格式异常未找到Bug信息")
if not bug_info:
raise RuntimeError("API返回数据格式异常Bug信息为空")
return bug_info
def get_bug(self, bug_id: str) -> Dict:
"""
获取bug详情
Args:
bug_id: bug ID
Returns:
Dict: bug详细信息
Raises:
RuntimeError: 获取失败时抛出
"""
params = {
'workspace_id': self.workspace_id,
'id': bug_id
}
result = self._make_request("bugs", method="GET", params=params)
# TAPD API返回格式: {"status": 1, "data": [{"Bug": {...}}]}
data = result.get('data', [])
if not isinstance(data, list) or len(data) == 0:
raise RuntimeError(f"未找到bug: {bug_id}")
# 取第一个元素
first_item = data[0]
# 提取Bug对象
if isinstance(first_item, dict) and 'Bug' in first_item:
bug_info = first_item['Bug']
else:
raise RuntimeError(f"API返回数据格式异常: {first_item}")
if not bug_info:
raise RuntimeError(f"未找到bug: {bug_id}")
return bug_info
def get_bugs_by_ids(self, bug_ids: List[str], limit: int = 200, fields: str = "id,status") -> Dict[str, Dict]:
"""
按多个 bug ID 批量获取 bug 信息
Args:
bug_ids: bug ID 列表
limit: 单页返回数量TAPD 最大支持 200
fields: 需要返回的字段,默认只取状态同步需要的字段
Returns:
Dict[str, Dict]: 以 bug ID 为 key 的 Bug 信息映射
Raises:
RuntimeError: 获取失败时抛出
"""
clean_bug_ids = []
seen_bug_ids = set()
for bug_id in bug_ids:
clean_bug_id = str(bug_id or "").strip()
if not clean_bug_id or clean_bug_id in seen_bug_ids:
continue
clean_bug_ids.append(clean_bug_id)
seen_bug_ids.add(clean_bug_id)
if not clean_bug_ids:
return {}
bounded_limit = max(1, min(int(limit), 200))
params = {
'workspace_id': self.workspace_id,
'id': ",".join(clean_bug_ids),
'limit': bounded_limit,
'page': 1,
'fields': fields
}
result = self._make_request("bugs", method="GET", params=params)
# TAPD API返回格式: {"status": 1, "data": [{"Bug": {...}}]}
data = result.get('data', [])
if not isinstance(data, list):
raise RuntimeError(f"API返回数据格式异常: {data}")
bug_map = {}
for item in data:
if not isinstance(item, dict) or 'Bug' not in item:
raise RuntimeError(f"API返回数据格式异常: {item}")
bug_info = item['Bug']
if not bug_info:
continue
returned_bug_id = str(bug_info.get('id', '')).strip()
if returned_bug_id:
bug_map[returned_bug_id] = bug_info
return bug_map
def get_bug_url(self, bug_id: str) -> str:
"""
生成bug的访问URL
Args:
bug_id: bug ID
Returns:
str: bug的访问URL
"""
# TAPD bug URL格式
return f"https://www.tapd.cn/{self.workspace_id}/bugtrace/bugs/view/{bug_id}"
def _check_file_size_before_upload(self, file_path: str, max_size: int) -> Tuple[bool, str]:
"""
上传前检查文件大小
Args:
file_path: 文件路径
max_size: 最大文件大小(字节)
Returns:
Tuple[bool, str]: (是否通过检查, 错误信息)
"""
try:
import os
file_size = os.path.getsize(file_path)
if file_size > max_size:
error_msg = f"文件大小超过限制: {file_size / 1024 / 1024:.2f}MB > {max_size / 1024 / 1024:.0f}MB"
return (False, error_msg)
return (True, "")
except Exception as e:
return (False, f"无法获取文件大小: {e}")
def upload_attachment(self, file_path: str, bug_id: str, max_size: int = 150 * 1024 * 1024,
upload_timeout: int = 120, max_retries: int = 3) -> Dict:
"""
上传单个附件到TAPD bug单
Args:
file_path: 文件路径
bug_id: bug ID
max_size: 最大文件大小字节默认150MB
upload_timeout: 上传超时时间默认120秒
max_retries: 最大重试次数默认3次
Returns:
Dict: 上传结果包含success、attachment_id、error_message
Raises:
RuntimeError: 上传失败时抛出
"""
import os
import time
filename = os.path.basename(file_path)
print(f" → 上传附件: {filename}")
# 检查文件是否存在
if not os.path.exists(file_path):
error_msg = f"文件不存在: {file_path}"
print(f"{error_msg}")
return {'success': False, 'error_message': error_msg}
# 检查文件大小
size_ok, error_msg = self._check_file_size_before_upload(file_path, max_size)
if not size_ok:
print(f"{error_msg}")
return {'success': False, 'error_message': error_msg}
# 准备上传参数
url = f"{self.BASE_URL}/files/upload_attachment"
data = {
'workspace_id': self.workspace_id,
'type': 'bug',
'entry_id': bug_id
}
# 准备日志记录的请求数据
log_request_data = {
"url": url,
"method": "POST",
"data": data,
"filename": filename,
"file_size": os.path.getsize(file_path),
"auth_user": self.api_user
}
# 重试上传
for attempt in range(1, max_retries + 1):
try:
if attempt > 1:
print(f" → 重试上传 ({attempt}/{max_retries})")
time.sleep(attempt * 2)
# 打开文件并上传
with open(file_path, 'rb') as f:
files = {'file': (filename, f, 'application/octet-stream')}
response = self.session.post(
url,
data=data,
files=files,
auth=self.auth,
timeout=upload_timeout
)
response.raise_for_status()
result = response.json()
# 检查TAPD API返回的状态
if result.get('status') != 1:
error_msg = result.get('info', '未知错误')
if attempt == max_retries:
# 记录API调用日志失败
self.logger.log_api_call(
api_type="tapd",
operation="upload_attachment",
request_data=log_request_data,
response_data=result,
success=False,
error_message=error_msg
)
print(f" ✗ 上传失败: {error_msg}")
return {'success': False, 'error_message': error_msg}
continue
# 上传成功提取附件ID
data_field = result.get('data', {})
attachment_info = data_field.get('Attachment', {})
attachment_id = attachment_info.get('id')
# 记录API调用日志成功
self.logger.log_api_call(
api_type="tapd",
operation="upload_attachment",
request_data=log_request_data,
response_data=result,
success=True
)
print(f" ✓ 上传成功 (附件ID: {attachment_id})")
return {
'success': True,
'attachment_id': attachment_id,
'error_message': None
}
except requests.exceptions.Timeout:
error_msg = f"上传超时 (>{upload_timeout}秒)"
if attempt == max_retries:
self.logger.log_api_call(
api_type="tapd",
operation="upload_attachment",
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
print(f"{error_msg}")
return {'success': False, 'error_message': error_msg}
except requests.exceptions.RequestException as e:
error_msg = f"上传失败: {e}"
if attempt == max_retries:
self.logger.log_api_call(
api_type="tapd",
operation="upload_attachment",
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
print(f"{error_msg}")
return {'success': False, 'error_message': error_msg}
except Exception as e:
error_msg = f"未预期的错误: {e}"
if attempt == max_retries:
self.logger.log_api_call(
api_type="tapd",
operation="upload_attachment",
request_data=log_request_data,
response_data={},
success=False,
error_message=error_msg
)
print(f"{error_msg}")
return {'success': False, 'error_message': error_msg}
return {'success': False, 'error_message': "上传失败(已达最大重试次数)"}
def upload_attachments_batch(self, file_paths: List[str], bug_id: str, max_size: int = 150 * 1024 * 1024,
upload_timeout: int = 120, max_retries: int = 3) -> Dict:
"""
批量上传附件到TAPD bug单
Args:
file_paths: 文件路径列表
bug_id: bug ID
max_size: 最大文件大小字节默认150MB
upload_timeout: 上传超时时间默认120秒
max_retries: 最大重试次数默认3次
Returns:
Dict: 上传结果包含success、success_files、failed_files
"""
print(f"\n → 开始批量上传附件 (共 {len(file_paths)} 个)")
success_files = []
failed_files = []
for idx, file_path in enumerate(file_paths, 1):
print(f"\n [{idx}/{len(file_paths)}]")
result = self.upload_attachment(file_path, bug_id, max_size, upload_timeout, max_retries)
if result['success']:
success_files.append({
'file_path': file_path,
'attachment_id': result['attachment_id']
})
else:
failed_files.append({
'file_path': file_path,
'error': result['error_message']
})
# 返回结果
if len(failed_files) > 0:
print(f"\n ⚠ 批量上传完成: 成功 {len(success_files)} 个, 失败 {len(failed_files)}")
return {
'success': False,
'success_files': success_files,
'failed_files': failed_files,
'error_message': f"部分附件上传失败 ({len(failed_files)}/{len(file_paths)})"
}
else:
print(f"\n ✓ 所有附件上传成功 ({len(success_files)} 个)")
return {
'success': True,
'success_files': success_files,
'failed_files': [],
'error_message': None
}