665 lines
23 KiB
Python
665 lines
23 KiB
Python
"""
|
||
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
|
||
}
|