url */ protected array $apps = [ 'Slots Magnet Revolution' => 'https://apps.apple.com/us/app/slots-magnet-revolution/id6759797102', 'Viking Mysteric Slots' => 'https://apps.apple.com/us/app/viking-mysteric-slots/id6759851510', 'Slots Chemistry Pirate' => 'https://apps.apple.com/us/app/slots-chemistry-pirate/id6759852588', // 'ceshi' => 'https://apps.apple.com/us/app/pixiepineslotsgrid/id6758513278', ]; /** * 连续多少次确认“下架”才发告警 */ protected int $delistedConfirmTimes = 3; /** * 连续多少次确认“异常”才发告警 */ protected int $unknownConfirmTimes = 3; /** * Redis 计数过期时间(秒) * 比如每 10 分钟跑一次,3600 表示 1 小时窗口内累计 */ protected int $counterExpireSeconds = 3600; /** * 每次请求最大重试次数 */ protected int $httpMaxAttempts = 3; /** * 请求间随机抖动范围(微秒) */ protected int $sleepMinMicroseconds = 200000; // 0.2 秒 protected int $sleepMaxMicroseconds = 500000; // 0.5 秒 public function handle() { $env = env('APP_ENV', 'local'); $results = []; $delistedNotified = []; $unknownNotified = []; foreach ($this->apps as $name => $url) { $result = $this->checkSingleApp($name, $url); $results[] = $result; Util::WriteLog( 'ios_app_store_check', sprintf( '[%s] %s | %s | %s', strtoupper($result['status']), $name, $url, $result['reason'] ) ); if ($result['status'] === 'online') { $this->clearCounters($result['app_id']); } elseif ($result['status'] === 'delisted') { $count = $this->increaseCounter($this->getDelistedCounterKey($result['app_id'])); $this->clearCounter($this->getUnknownCounterKey($result['app_id'])); if ($count >= $this->delistedConfirmTimes) { if (!$this->hasAlreadyNotified('delisted', $result['app_id'])) { $delistedNotified[] = [ 'name' => $name, 'url' => $url, 'reason' => $result['reason'], 'count' => $count, ]; $this->markNotified('delisted', $result['app_id']); } } } elseif ($result['status'] === 'unknown') { $count = $this->increaseCounter($this->getUnknownCounterKey($result['app_id'])); $this->clearCounter($this->getDelistedCounterKey($result['app_id'])); if ($count >= $this->unknownConfirmTimes) { if (!$this->hasAlreadyNotified('unknown', $result['app_id'])) { $unknownNotified[] = [ 'name' => $name, 'url' => $url, 'reason' => $result['reason'], 'count' => $count, ]; $this->markNotified('unknown', $result['app_id']); } } } // 请求间抖动,降低被 Apple 限流概率 usleep(random_int($this->sleepMinMicroseconds, $this->sleepMaxMicroseconds)); } if (!empty($delistedNotified)) { $this->notifyDelisted($env, $delistedNotified); $this->warn('已发送下架告警: ' . count($delistedNotified) . ' 个'); } if (!empty($unknownNotified)) { $this->notifyUnknown($env, $unknownNotified); $this->warn('已发送检测异常告警: ' . count($unknownNotified) . ' 个'); } foreach ($results as $item) { $this->line(sprintf( '[%s] %s - %s', strtoupper($item['status']), $item['name'], $item['reason'] )); } if (empty($delistedNotified) && empty($unknownNotified)) { $this->info('本次检测完成,无需告警。'); } return empty($delistedNotified) ? 0 : 1; } /** * 检测单个 App 状态 * * 返回: * [ * 'name' => string, * 'url' => string, * 'app_id' => string, * 'status' => 'online'|'delisted'|'unknown', * 'reason' => string, * ] */ protected function checkSingleApp(string $name, string $appStoreUrl): array { $parsed = $this->parseAppStoreUrl($appStoreUrl); if (!$parsed) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => 'unknown', 'status' => 'unknown', 'reason' => '无法解析 App Store URL 中的国家或 App ID', ]; } $country = $parsed['country']; $appId = $parsed['app_id']; $lookupUrl = sprintf( 'https://itunes.apple.com/lookup?id=%s&country=%s&entity=software', $appId, $country ); $resp = $this->httpGetWithRetry($lookupUrl, $this->httpMaxAttempts); if (!empty($resp['error'])) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'unknown', 'reason' => 'Lookup 请求失败: ' . $resp['error'], ]; } $code = (int)($resp['code'] ?? 0); $body = (string)($resp['body'] ?? ''); if ($code === 200) { $json = json_decode($body, true); if (!is_array($json)) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'unknown', 'reason' => 'Lookup 返回非有效 JSON', ]; } $resultCount = (int)($json['resultCount'] ?? 0); $results = $json['results'] ?? []; if ($resultCount <= 0 || empty($results)) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'delisted', 'reason' => "Lookup 无结果(resultCount=0, country={$country}, appId={$appId})", ]; } $app = $results[0]; $trackId = (string)($app['trackId'] ?? ''); $trackViewUrl = (string)($app['trackViewUrl'] ?? ''); if ($trackId !== (string)$appId) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'unknown', 'reason' => 'Lookup 返回结果异常:trackId 不匹配', ]; } if ($trackViewUrl !== '' && stripos($trackViewUrl, "/{$country}/") === false) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'online', 'reason' => 'Lookup 返回应用信息(国家链接与配置不一致,但应用存在)', ]; } // 成功在线时,清掉已通知标记,这样未来再次异常还能重新通知 $this->clearNotifiedFlags($appId); return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'online', 'reason' => 'Lookup 正常返回应用信息', ]; } // 这些不能直接判定为下架 if (in_array($code, [403, 404, 429, 500, 502, 503, 504], true)) { return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'unknown', 'reason' => "Lookup HTTP {$code}", ]; } return [ 'name' => $name, 'url' => $appStoreUrl, 'app_id' => $appId, 'status' => 'unknown', 'reason' => "Lookup HTTP {$code}", ]; } /** * 从 App Store 链接中提取国家和 app id * * 示例: * https://apps.apple.com/us/app/slots-magnet-revolution/id6759797102 */ protected function parseAppStoreUrl(string $url): ?array { $pattern = '#https?://apps\.apple\.com/([a-z]{2})/app/.*/id(\d+)#i'; if (!preg_match($pattern, $url, $matches)) { return null; } return [ 'country' => strtolower($matches[1]), 'app_id' => $matches[2], ]; } /** * GET 请求,带简单重试和退避 * * 返回: * [ * 'code' => int, * 'body' => string, * 'error' => string, * ] */ protected function httpGetWithRetry(string $url, int $maxAttempts = 3): array { $attempt = 0; $last = [ 'code' => 0, 'body' => '', 'error' => '', ]; while ($attempt < $maxAttempts) { $attempt++; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Accept-Language: en-US,en;q=0.9', 'Cache-Control: no-cache', ], CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36', ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); $last = [ 'code' => $code, 'body' => is_string($body) ? $body : '', 'error' => $error ?: '', ]; if ($last['error'] === '' && $code === 200) { return $last; } $shouldRetry = $last['error'] !== '' || in_array($code, [429, 500, 502, 503, 504], true); if (!$shouldRetry || $attempt >= $maxAttempts) { return $last; } if ($attempt == 1) { $sleepSeconds = 2; } elseif ($attempt == 2) { $sleepSeconds = 5; } else { $sleepSeconds = 8; } sleep($sleepSeconds); } return $last; } protected function getDelistedCounterKey(string $appId): string { return "ios:appstore:delisted:{$appId}"; } protected function getUnknownCounterKey(string $appId): string { return "ios:appstore:unknown:{$appId}"; } protected function getDelistedNotifiedKey(string $appId): string { return "ios:appstore:notified:delisted:{$appId}"; } protected function getUnknownNotifiedKey(string $appId): string { return "ios:appstore:notified:unknown:{$appId}"; } protected function increaseCounter(string $key): int { $count = (int) Redis::incr($key); Redis::expire($key, $this->counterExpireSeconds); return $count; } protected function clearCounters(string $appId): void { $this->clearCounter($this->getDelistedCounterKey($appId)); $this->clearCounter($this->getUnknownCounterKey($appId)); } protected function clearCounter(string $key): void { try { Redis::del($key); } catch (\Throwable $e) { Util::WriteLog('ios_app_store_check', 'Redis del error: ' . $e->getMessage()); } } /** * 防止达到阈值后每次 cron 都重复发同类告警 */ protected function hasAlreadyNotified(string $type, string $appId): bool { try { $key = $type === 'delisted' ? $this->getDelistedNotifiedKey($appId) : $this->getUnknownNotifiedKey($appId); return (bool) Redis::exists($key); } catch (\Throwable $e) { Util::WriteLog('ios_app_store_check', 'Redis exists error: ' . $e->getMessage()); return false; } } protected function markNotified(string $type, string $appId): void { try { $key = $type === 'delisted' ? $this->getDelistedNotifiedKey($appId) : $this->getUnknownNotifiedKey($appId); // 通知标记也设置一个过期,防止永远不恢复 Redis::setex($key, $this->counterExpireSeconds, 1); } catch (\Throwable $e) { Util::WriteLog('ios_app_store_check', 'Redis setex error: ' . $e->getMessage()); } } protected function clearNotifiedFlags(string $appId): void { try { Redis::del( $this->getDelistedNotifiedKey($appId), $this->getUnknownNotifiedKey($appId) ); } catch (\Throwable $e) { Util::WriteLog('ios_app_store_check', 'Redis clear notified flags error: ' . $e->getMessage()); } } protected function notifyDelisted(string $env, array $items): void { $lines = ["[{$env}] iOS App Store 下架检测告警"]; foreach ($items as $item) { $lines[] = "• {$item['name']}"; $lines[] = " 原因: {$item['reason']}"; $lines[] = " 连续次数: {$item['count']}"; $lines[] = " 链接: {$item['url']}"; } $this->sendTelegramMessage(implode("\n", $lines)); } protected function notifyUnknown(string $env, array $items): void { $lines = ["[{$env}] iOS App Store 检测异常告警"]; foreach ($items as $item) { $lines[] = "• {$item['name']}"; $lines[] = " 原因: {$item['reason']}"; $lines[] = " 连续次数: {$item['count']}"; $lines[] = " 链接: {$item['url']}"; } $this->sendTelegramMessage(implode("\n", $lines)); } protected function sendTelegramMessage(string $message): void { try { TelegramBot::getDefault()->sendMsg($message); } catch (\Throwable $e) { Util::WriteLog('ios_app_store_check', 'Telegram send error: ' . $e->getMessage()); $this->error('Telegram 发送失败: ' . $e->getMessage()); } } }