314 lines
10 KiB
Python
314 lines
10 KiB
Python
"""
|
||
统一采集结果码模块
|
||
|
||
错误分类原则:按责任方划分
|
||
- SUCCESS: 成功
|
||
- INFRA_ERROR: 基础设施错误 (环境/设备问题)
|
||
- APP_ERROR: 应用错误 (应用本身的问题)
|
||
- BUSINESS_ERROR: 业务限制 (应用功能限制)
|
||
"""
|
||
from enum import IntEnum
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, Optional, Tuple
|
||
|
||
|
||
class ResultCode(IntEnum):
|
||
"""采集结果退出码"""
|
||
SUCCESS = 0
|
||
ERROR_USER = 1
|
||
ADB_ERROR = 2
|
||
NETWORK_ERROR = 3
|
||
SIMULATOR_ERROR = 4
|
||
EXPLORATION_STUCK = 5
|
||
APP_CRASH = 6
|
||
APP_NEED_UPDATE = 7
|
||
APP_LAUNCH_ERROR = 8
|
||
ERROR_GENERAL = -1
|
||
|
||
|
||
class ErrorCategory(IntEnum):
|
||
"""错误大类 - 按责任方划分"""
|
||
SUCCESS = 0
|
||
INFRA_ERROR = 1
|
||
APP_ERROR = 2
|
||
BUSINESS_ERROR = 3
|
||
DOWNLOAD_ERROR = 4
|
||
|
||
|
||
class InfraError(IntEnum):
|
||
"""基础设施错误原因"""
|
||
ADB_ERROR = 1
|
||
NETWORK_ERROR = 2
|
||
EMULATOR_CRASH = 3
|
||
EMULATOR_RECOVERY_FAILED = 4
|
||
PCAP_START_FAILED = 5
|
||
DOWNLOAD_FAILED = 6
|
||
AIRTEST_INIT_FAILED = 7
|
||
EMULATOR_START_FAILED = 8
|
||
|
||
|
||
class DownloadError(IntEnum):
|
||
"""下载错误原因 - 细分"""
|
||
OTHER = 0
|
||
REGION_RESTRICTED = 1
|
||
ACCOUNT_BANNED = 2
|
||
APP_NOT_FOUND = 3
|
||
INSTALL_FAILED = 4
|
||
DOWNLOAD_TIMEOUT = 5
|
||
NETWORK_ERROR = 6
|
||
SOURCE_UNAVAILABLE = 7
|
||
ALL_SOURCES_FAILED = 8
|
||
COUNTRY_LOCKED = 9 # legacy aggregated terminal code
|
||
INCOMPATIBLE = 10
|
||
|
||
|
||
class AppError(IntEnum):
|
||
"""应用错误原因"""
|
||
CRASH = 1
|
||
LAUNCH_ERROR = 2
|
||
DISCONTINUED = 3
|
||
INCOMPATIBLE = 4
|
||
NEED_UPDATE = 5
|
||
ROOT_MODE_UNSUPPORTED = 6
|
||
|
||
|
||
class BusinessError(IntEnum):
|
||
"""业务限制原因"""
|
||
OTHER = 0
|
||
LOGIN_FAILED = 1
|
||
REGISTER_FAILED = 2
|
||
PAYMENT_REQUIRED = 3
|
||
REGION_RESTRICTED = 4
|
||
INVITE_ONLY = 5
|
||
VIRTUAL_PHONE_INVALID = 6
|
||
VIRTUAL_IDENTITY_INVALID = 7
|
||
VALIDATION_FAILED = 8
|
||
PHYSICAL_ID_REQUIRED = 9
|
||
NO_SCENARIO = 10
|
||
NORMAL_NO_ACTION = 11
|
||
|
||
|
||
INFRA_ERROR_DESC = {
|
||
InfraError.ADB_ERROR: "ADB 断联",
|
||
InfraError.NETWORK_ERROR: "网络异常",
|
||
InfraError.EMULATOR_CRASH: "模拟器崩溃",
|
||
InfraError.EMULATOR_RECOVERY_FAILED: "模拟器恢复失败",
|
||
InfraError.PCAP_START_FAILED: "PCAP 启动失败",
|
||
InfraError.DOWNLOAD_FAILED: "下载失败",
|
||
InfraError.AIRTEST_INIT_FAILED: "Airtest 初始化失败",
|
||
InfraError.EMULATOR_START_FAILED: "模拟器启动失败",
|
||
}
|
||
|
||
DOWNLOAD_ERROR_DESC = {
|
||
DownloadError.OTHER: "下载失败",
|
||
DownloadError.REGION_RESTRICTED: "Google Play 单国家锁区",
|
||
DownloadError.ACCOUNT_BANNED: "账号被封禁",
|
||
DownloadError.APP_NOT_FOUND: "Google Play 单国家未找到应用",
|
||
DownloadError.INCOMPATIBLE: "Google Play 当前设备不兼容",
|
||
DownloadError.INSTALL_FAILED: "安装失败",
|
||
DownloadError.DOWNLOAD_TIMEOUT: "下载超时",
|
||
DownloadError.NETWORK_ERROR: "下载网络异常",
|
||
DownloadError.SOURCE_UNAVAILABLE: "本地导入源不可用",
|
||
DownloadError.ALL_SOURCES_FAILED: "所有下载源失败",
|
||
DownloadError.COUNTRY_LOCKED: "历史聚合下载错误(Google Play 终态且本地兜底失败)",
|
||
}
|
||
|
||
APP_ERROR_DESC = {
|
||
AppError.CRASH: "应用闪退",
|
||
AppError.LAUNCH_ERROR: "启动异常",
|
||
AppError.DISCONTINUED: "应用停服",
|
||
AppError.INCOMPATIBLE: "应用不兼容",
|
||
AppError.NEED_UPDATE: "应用持续跳转谷歌商店",
|
||
AppError.ROOT_MODE_UNSUPPORTED: "Root 环境下无法使用",
|
||
}
|
||
|
||
BUSINESS_ERROR_DESC = {
|
||
BusinessError.OTHER: "其他原因",
|
||
BusinessError.LOGIN_FAILED: "登录失败",
|
||
BusinessError.REGISTER_FAILED: "注册失败",
|
||
BusinessError.PAYMENT_REQUIRED: "需付费",
|
||
BusinessError.REGION_RESTRICTED: "地区限制",
|
||
BusinessError.INVITE_ONLY: "需邀请码",
|
||
BusinessError.VIRTUAL_PHONE_INVALID: "虚拟手机号无效",
|
||
BusinessError.VIRTUAL_IDENTITY_INVALID: "虚拟身份无效",
|
||
BusinessError.VALIDATION_FAILED: "校验失败",
|
||
BusinessError.PHYSICAL_ID_REQUIRED: "需实体证件",
|
||
BusinessError.NO_SCENARIO: "无登录注册场景",
|
||
BusinessError.NORMAL_NO_ACTION: "测试正常无新动作",
|
||
}
|
||
|
||
|
||
STUCK_TO_ERROR: Dict[int, Tuple[ErrorCategory, int, str]] = {
|
||
0: (ErrorCategory.BUSINESS_ERROR, BusinessError.OTHER, "其他原因"),
|
||
1: (ErrorCategory.BUSINESS_ERROR, BusinessError.LOGIN_FAILED, "登录注册需人工辅助"),
|
||
2: (ErrorCategory.APP_ERROR, AppError.LAUNCH_ERROR, "启动异常"),
|
||
3: (ErrorCategory.SUCCESS, 0, "测试正常无新动作"),
|
||
4: (ErrorCategory.BUSINESS_ERROR, BusinessError.REGION_RESTRICTED, "地区限制"),
|
||
5: (ErrorCategory.BUSINESS_ERROR, BusinessError.PAYMENT_REQUIRED, "需付费"),
|
||
6: (ErrorCategory.BUSINESS_ERROR, BusinessError.VIRTUAL_PHONE_INVALID, "虚拟手机号无效"),
|
||
7: (ErrorCategory.BUSINESS_ERROR, BusinessError.VIRTUAL_IDENTITY_INVALID, "虚拟身份无效"),
|
||
8: (ErrorCategory.BUSINESS_ERROR, BusinessError.VALIDATION_FAILED, "注册校验失败"),
|
||
9: (ErrorCategory.BUSINESS_ERROR, BusinessError.INVITE_ONLY, "非开放注册"),
|
||
10: (ErrorCategory.APP_ERROR, AppError.DISCONTINUED, "应用停服"),
|
||
11: (ErrorCategory.BUSINESS_ERROR, BusinessError.PHYSICAL_ID_REQUIRED, "需实体证件"),
|
||
12: (ErrorCategory.APP_ERROR, AppError.ROOT_MODE_UNSUPPORTED, "Root 环境下无法使用"),
|
||
}
|
||
|
||
|
||
NO_RETRY_ERRORS = (
|
||
{(ErrorCategory.APP_ERROR, error) for error in AppError}
|
||
| {
|
||
(ErrorCategory.BUSINESS_ERROR, error)
|
||
for error in BusinessError
|
||
if error not in {BusinessError.NO_SCENARIO, BusinessError.NORMAL_NO_ACTION}
|
||
}
|
||
)
|
||
DOWNLOAD_TERMINAL_GOOGLE_PLAY_ERRORS = {
|
||
int(DownloadError.REGION_RESTRICTED),
|
||
int(DownloadError.APP_NOT_FOUND),
|
||
int(DownloadError.INCOMPATIBLE),
|
||
}
|
||
def _normalize_error_category(category: Any) -> Optional[ErrorCategory]:
|
||
if isinstance(category, ErrorCategory):
|
||
return category
|
||
if isinstance(category, str):
|
||
try:
|
||
return ErrorCategory[category]
|
||
except KeyError:
|
||
return None
|
||
try:
|
||
return ErrorCategory(int(category))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _normalize_error_code(code: Any) -> Optional[int]:
|
||
try:
|
||
return int(code)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _normalize_download_errors(download_errors: Any) -> Dict[str, Dict[str, Any]]:
|
||
return download_errors if isinstance(download_errors, dict) else {}
|
||
|
||
|
||
def _is_terminal_download_combo(error_code: Any, download_errors: Any = None) -> bool:
|
||
normalized_code = _normalize_error_code(error_code)
|
||
if normalized_code == int(DownloadError.COUNTRY_LOCKED):
|
||
return True
|
||
|
||
errors = _normalize_download_errors(download_errors)
|
||
if not errors:
|
||
return False
|
||
|
||
google_play_codes = []
|
||
local_codes = []
|
||
for source, detail in errors.items():
|
||
if not isinstance(detail, dict):
|
||
return False
|
||
detail_code = _normalize_error_code(detail.get("code"))
|
||
if detail_code is None:
|
||
return False
|
||
source_name = str(source or "").strip().lower()
|
||
if source_name.startswith("google_play:"):
|
||
google_play_codes.append(detail_code)
|
||
elif source_name == "local":
|
||
local_codes.append(detail_code)
|
||
|
||
if not google_play_codes or not local_codes:
|
||
return False
|
||
if any(code not in DOWNLOAD_TERMINAL_GOOGLE_PLAY_ERRORS for code in google_play_codes):
|
||
return False
|
||
return True
|
||
|
||
|
||
def is_no_retry_error(category: Any, code: Any, download_errors: Any = None) -> bool:
|
||
normalized_category = _normalize_error_category(category)
|
||
normalized_code = _normalize_error_code(code)
|
||
if normalized_category is None or normalized_code is None:
|
||
return False
|
||
if (normalized_category, normalized_code) in NO_RETRY_ERRORS:
|
||
return True
|
||
if normalized_category == ErrorCategory.DOWNLOAD_ERROR:
|
||
return _is_terminal_download_combo(normalized_code, download_errors=download_errors)
|
||
return False
|
||
|
||
|
||
@dataclass
|
||
class ErrorInfo:
|
||
"""标准化错误信息"""
|
||
category: ErrorCategory
|
||
code: int
|
||
reason: str
|
||
details: Optional[str] = None
|
||
|
||
def to_report_dict(self) -> dict:
|
||
"""转换为上报格式"""
|
||
result = {
|
||
"type": f"{self.category.name}/{self.code}",
|
||
"category": self.category.name,
|
||
"code": self.code,
|
||
"reason": self.reason,
|
||
}
|
||
if self.details:
|
||
result["details"] = self.details
|
||
return result
|
||
|
||
@classmethod
|
||
def success(cls) -> "ErrorInfo":
|
||
return cls(ErrorCategory.SUCCESS, 0, "成功")
|
||
|
||
@classmethod
|
||
def from_stuck_reason(cls, stuck_code: int, details: str = None) -> "ErrorInfo":
|
||
"""从 stuck_reason_code 创建 ErrorInfo"""
|
||
category, code, reason = STUCK_TO_ERROR.get(
|
||
stuck_code,
|
||
(ErrorCategory.BUSINESS_ERROR, BusinessError.OTHER, "其他原因")
|
||
)
|
||
return cls(category, code, reason, details)
|
||
|
||
@classmethod
|
||
def infra(cls, error: InfraError, details: str = None) -> "ErrorInfo":
|
||
"""创建基础设施错误"""
|
||
return cls(
|
||
ErrorCategory.INFRA_ERROR,
|
||
error,
|
||
INFRA_ERROR_DESC[error],
|
||
details
|
||
)
|
||
|
||
@classmethod
|
||
def app(cls, error: AppError, details: str = None) -> "ErrorInfo":
|
||
"""创建应用错误"""
|
||
return cls(
|
||
ErrorCategory.APP_ERROR,
|
||
error,
|
||
APP_ERROR_DESC[error],
|
||
details
|
||
)
|
||
|
||
@classmethod
|
||
def business(cls, error: BusinessError, details: str = None) -> "ErrorInfo":
|
||
"""创建业务限制错误"""
|
||
return cls(
|
||
ErrorCategory.BUSINESS_ERROR,
|
||
error,
|
||
BUSINESS_ERROR_DESC[error],
|
||
details
|
||
)
|
||
|
||
@classmethod
|
||
def download(cls, error: "DownloadError", details: str = None) -> "ErrorInfo":
|
||
"""创建下载错误"""
|
||
return cls(
|
||
ErrorCategory.DOWNLOAD_ERROR,
|
||
error,
|
||
DOWNLOAD_ERROR_DESC.get(error, "下载失败"),
|
||
details
|
||
)
|
||
|
||
def is_no_retry(self, download_errors: Any = None) -> bool:
|
||
"""判断是否为不可重试错误"""
|
||
return is_no_retry_error(self.category, self.code, download_errors=download_errors)
|