G41_TAPD_BUG_SYNC/src2/tapd_api.py

382 lines
12 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交互查询需求Story信息
与任务一的区别:
- 任务一创建和管理Bug单
- 任务二查询需求Story状态信息
"""
import os
import requests
import time
from typing import Dict, Optional, Any
from requests.auth import HTTPBasicAuth
# 导入任务二专用的日志模块
from src2.logger import get_task2_logger
# ============================================================
# 自定义异常类
# ============================================================
class StoryNotFoundException(Exception):
"""当TAPD需求Story不存在时抛出的异常"""
pass
# TAPD状态值映射表
# 将API返回的状态代码转换为中文显示文本
STATUS_MAPPING = {
"status_5": "进行中",
"status_7": "未开始",
"status_8": "已完成",
"status_9": "待验收",
"status_10": "联调",
"status_12": "取消",
"status_13": "待评审",
}
# 需求终态列表(这些状态不需要持续同步)
TERMINAL_STATUSES = ['已完成', '取消']
def map_status(status_code: str) -> str:
"""
将TAPD状态代码转换为中文显示文本
Args:
status_code: TAPD API返回的状态代码"status_5"
Returns:
str: 中文显示文本(如 "进行中"),未知状态返回原始值
"""
if not status_code:
return "未知"
return STATUS_MAPPING.get(status_code, status_code)
class TAPDStoryApi:
"""TAPD需求API封装类任务二专用"""
# TAPD API基础URL与任务一相同
BASE_URL = "https://tapd-api.bilibili.co/tapd"
# 发布计划字段名称
PLAN_FIELD_NAME = "release_id"
def __init__(self, workspace_id: str, test_mode: bool = False):
"""
初始化TAPD Story 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_task2_logger()
# 计划字段映射缓存ID -> 中文名称)
self._plan_mapping = None
print(f" ✓ TAPD Story API初始化完成 (workspace_id: {workspace_id})")
if test_mode:
print(f" ⚠ 测试模式已启用将显示所有API调用的详细信息")
def _make_request(self, endpoint: str, params: Optional[Dict] = None) -> Dict:
"""
发起TAPD API GET请求的通用方法支持429错误重试
Args:
endpoint: API端点"stories"
params: URL查询参数
Returns:
Dict: API响应数据
Raises:
RuntimeError: API调用失败时抛出
"""
url = f"{self.BASE_URL}/{endpoint}"
# 准备日志记录的请求数据
log_request_data = {
"url": url,
"method": "GET",
"params": params,
"auth_user": self.api_user
}
# 测试模式:显示请求信息
if self.test_mode:
print("\n" + "=" * 60)
print(f"【测试模式】TAPD API调用: {endpoint}")
print("=" * 60)
print(f"请求URL: {url}")
if params:
print(f"URL参数:")
for key, value in params.items():
print(f" {key}: {value}")
# 429错误重试逻辑最多重试1次
max_retries = 1
retry_count = 0
while retry_count <= max_retries:
try:
response = self.session.get(
url,
params=params,
auth=self.auth,
timeout=30
)
# 测试模式:显示响应信息
if self.test_mode:
print(f"\n响应状态码: {response.status_code}")
try:
import json
result = response.json()
print(f"响应数据:")
print(json.dumps(result, ensure_ascii=False, indent=2))
except:
print(f"响应内容: {response.text[:500]}")
print("=" * 60)
# 检查是否是429错误在raise_for_status之前检查
if response.status_code == 429:
if retry_count < max_retries:
retry_count += 1
wait_seconds = 60
print(f"\n⚠️ 触发TAPD API限流 (429 Too Many Requests)")
print(f" [开始等待] 等待 {wait_seconds} 秒后重试... (第 {retry_count}/{max_retries} 次重试)")
print(f" [重要] 在等待期间,代码会阻塞在这里,不会继续处理其他记录")
# 记录429错误日志
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={"status_code": 429, "retry_count": retry_count},
success=False,
error_message=f"429 Too Many Requests, 等待{wait_seconds}秒后重试"
)
time.sleep(wait_seconds)
print(f" [等待结束] 开始重试请求...")
continue # 重试
else:
# 已达到最大重试次数,抛出异常
error_msg = "TAPD API限流 (429 Too Many Requests),重试后仍然失败"
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data={"status_code": 429, "retry_count": retry_count},
success=False,
error_message=error_msg
)
raise RuntimeError(error_msg)
# 对于非429错误调用raise_for_status检查HTTP状态
response.raise_for_status()
result = response.json()
# 检查TAPD API返回的状态
if result.get('status') != 1:
error_msg = result.get('info', '未知错误')
self.logger.log_api_call(
api_type="tapd",
operation=endpoint,
request_data=log_request_data,
response_data=result,
success=False,
error_message=error_msg
)
raise RuntimeError(f"TAPD API调用失败: {error_msg}")
# 记录成功日志
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}"
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:
# 如果是HTTPError且状态码是429由上面的status_code检查处理
# 这里不应该到达因为429在response.status_code检查时已处理
error_msg = f"TAPD API请求失败: {e}"
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)
# 理论上不会到达这里
raise RuntimeError("TAPD API请求失败未知错误")
def get_story(self, story_id: str) -> Dict:
"""
获取需求详情
Args:
story_id: 需求ID
Returns:
Dict: 需求详细信息
Raises:
RuntimeError: 获取失败时抛出
"""
params = {
'workspace_id': self.workspace_id,
'id': story_id
}
result = self._make_request("stories", params=params)
# TAPD API返回格式: {"status": 1, "data": [{"Story": {...}}]}
data = result.get('data', [])
if not isinstance(data, list) or len(data) == 0:
raise StoryNotFoundException(f"未找到需求: {story_id}")
# 取第一个元素
first_item = data[0]
# 提取Story对象
if isinstance(first_item, dict) and 'Story' in first_item:
story_info = first_item['Story']
else:
raise RuntimeError(f"API返回数据格式异常: {first_item}")
if not story_info:
raise StoryNotFoundException(f"未找到需求: {story_id}")
# 转换状态为中文
raw_status = story_info.get('status', '')
story_info['raw_status'] = raw_status
story_info['status'] = map_status(raw_status)
return story_info
def get_story_url(self, story_id: str) -> str:
"""
生成需求的访问URL
Args:
story_id: 需求ID
Returns:
str: 需求的访问URL
"""
return f"https://www.tapd.cn/{self.workspace_id}/prong/stories/view/{story_id}"
def get_story_fields_info(self) -> Dict:
"""
获取需求所有字段及候选值
Returns:
Dict: 字段信息,包含各字段的名称、选项等
Raises:
RuntimeError: 获取失败时抛出
"""
params = {
'workspace_id': self.workspace_id
}
result = self._make_request("stories/get_fields_info", params=params)
return result.get('data', {})
def get_plan_mapping(self) -> Dict[str, str]:
"""
获取发布计划字段的ID到中文名称映射
Returns:
Dict[str, str]: 发布计划ID到中文名称的映射
例如: {"1010104801000069739": "v2test", ...}
"""
# 获取字段信息
fields_info = self.get_story_fields_info()
# 提取计划字段的options
plan_field = fields_info.get(self.PLAN_FIELD_NAME, {})
options = plan_field.get('options', {})
# 缓存映射
self._plan_mapping = options
if self.test_mode:
print(f"\n【测试模式】计划字段映射:")
for plan_id, plan_name in options.items():
print(f" {plan_id} -> {plan_name}")
return options
def map_plan_id_to_name(self, plan_id: str) -> str:
"""
将发布计划ID转换为中文名称
Args:
plan_id: 发布计划ID"1010104801000069739"
Returns:
str: 中文名称(如 "v2test"),未找到则返回空字符串
"""
if not plan_id or plan_id == "0":
return ""
# 如果映射未初始化,先获取
if self._plan_mapping is None:
self.get_plan_mapping()
return self._plan_mapping.get(plan_id, "")
if __name__ == "__main__":
print("=== TAPD Story API 测试 ===\n")
print("请使用 test_phase2.py 进行完整测试")