autool-dispatcher/result_codes.py
2026-06-17 19:50:39 +08:00

332 lines
11 KiB
Python
Raw Permalink 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.

"""
统一采集结果码模块
错误分类原则:按责任方划分
- 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
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
WORKER_TIMEOUT = 9
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: "模拟器启动失败",
InfraError.WORKER_TIMEOUT: "Worker 心跳超时",
}
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 = {
"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
)
@classmethod
def from_report_dict(cls, data: dict) -> "ErrorInfo":
"""从上报字典解析"""
if not data:
return cls(ErrorCategory.SUCCESS, 0, "成功")
category_str = data.get('category', 'SUCCESS')
try:
category = ErrorCategory[category_str]
except KeyError:
category = ErrorCategory.SUCCESS
code = data.get('code', 0)
reason = data.get('reason', '')
details = data.get('details')
return cls(category, code, reason, details)
def is_no_retry(self, download_errors: Any = None) -> bool:
"""判断是否为不可重试错误"""
return is_no_retry_error(self.category, self.code, download_errors=download_errors)