""" 统一采集结果码模块 错误分类原则:按责任方划分 - 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)