import 'dart:io'; import 'package:get/get.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:logger/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; import '../models/channel_app_model.dart'; import 'network/api.dart'; /// 版本更新服务类 class VersionUpdateService extends GetxService { static VersionUpdateService get to => Get.find(); // 静态应用信息(内存缓存) static String? _appVersion = ""; //应用版本号 static String? _buildNumber = ""; //构建号 static String? _platform = ""; //平台类型 static String? _deviceBrand = ""; //设备品牌 static UserChannel? _channel; //渠道 static String? _latestVersion = ""; //最新版本号+构建号 static bool? _hasUpdate = false; //是否有更新 static bool? _isForceUpdate = false; //是否强制更新 static bool? _isReviewing = false; //本机版本是否正处于渠道审核期(服务端判定) static String? _updateUrl = ""; //更新链接 static AppInfo? _appInfo; //应用信息 static bool? _isShowTourists = false; //是否显示游客登录 /// 这一版是否显示实名认证并按实名限制功能(服务端结论,与游客登录同一个口子)。 /// /// 做成 Rx:「未认证」角标、「我的」页实名行在 Obx 里读它,结论到达/变化时要能跟着显隐。 /// 拿不到配置(断网、接口失败、渠道没配)一律 false = 隐藏实名、放行功能(2026-09-23 定的失败方向)。 static final RxBool showIdVerify = false.obs; static bool? _allowSkipDeviceBinding = false; //是否允许跳过设备绑定 static DateTime? _lastCheckTime; //上次检查时间 static const int _cacheIntervalMinutes = 10; // 可配置化 static final String? _countryCode = Get.deviceLocale?.countryCode; //国家地区编码 static final String? _languageCode = Get.deviceLocale?.languageCode; //语言编码 // ignore: non_constant_identifier_names // static final String? _SERVER_URL = (_countryCode == 'CN') // ? dotenv.env['SERVER_URL'] // : dotenv.env['HW_SERVER_URL']; //服务器地址 // 添加 getter 方法来访问缓存的静态数据 /// 获取缓存的应用版本 String get cachedAppVersion => _appVersion ?? ''; /// 获取缓存的构建号 String get cachedBuildNumber => _buildNumber ?? ''; /// 获取缓存的平台信息 String get cachedPlatform => _platform ?? ''; /// 获取缓存的设备品牌 String get cachedDeviceBrand => _deviceBrand ?? ''; /// 获取缓存的渠道信息 UserChannel get cachedChannel => _channel ?? (_platform?.toLowerCase() == 'ios' ? UserChannel.GLOBAL_APPLE : UserChannel.GLOBAL_GOOGLE); /// 获取缓存的最新版本 String get cachedLatestVersion => _latestVersion ?? ''; /// 获取缓存的是否有更新 bool get cachedHasUpdate => _hasUpdate ?? false; /// 获取缓存的是否强制更新 bool get cachedIsForceUpdate => _isForceUpdate ?? false; /// 获取缓存的更新地址 String get cachedUpdateUrl => _updateUrl ?? ''; /// 本机版本是否正处于渠道审核期(后台「版本控制」填的送审版本号等于本机版本)。 /// 服务端算好的结论,供「审核期隐藏未过审功能」用;目前还没有 UI 消费它。 bool get cachedIsReviewing => _isReviewing ?? false; /// 获取缓存的应用信息 AppInfo? get cachedAppInfo => _appInfo; /// 获取缓存的游客登录状态 bool get cachedShowTourists => _isShowTourists ?? false; /// 获取缓存的是否允许跳过设备绑定 bool get cachedAllowSkipDeviceBinding => _allowSkipDeviceBinding ?? false; /// 获取上次检查时间 DateTime? get lastCheckTime => _lastCheckTime; /// 获取缓存的国家地区编码 String get cachedCountryCode => _countryCode ?? ''; /// 获取缓存的语言编码 String get cachedLanguageCode => _languageCode ?? ''; /// 获取缓存的服务器地址 // String get cachedServerUrl => _SERVER_URL ?? ''; final Logger _logger = Logger(); // @override // void onInit() { // getCurrentVersion(); // getCurrentPlatformInfo(); // super.onInit(); // } /// 检查版本更新。 /// /// 走**单数**接口 `user_getchannelapp`:只要本渠道这一条,且服务端把 /// `needupdate/forceupdate/reviewing` 三个结论直接算好下发(见 api_getchannelapp.go), /// 客户端不再自己比版本号。 /// /// 为什么不是复数的 `user_getchannelapps`: /// - 它取回全部渠道,而客户端只用得上自己这一条 —— 早先那套「环境渠道 + 品牌渠道」 /// 双取的逻辑早已失效(`_channel` 在 getCurrentPlatformInfo 里就被改写成品牌渠道, /// 两个变量取到的是同一条),留着只是多传一堆用不到的数据。 /// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口 /// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。 /// 单数接口本来就在白名单里。 /// 网页下载页:Android 上打开服务端给的地址失败时的兜底(页面会按品牌跳商店 / 直接给安装包)。 /// 与后台「版本控制」页推荐填的默认地址、服务端 comm.DefaultDistDomain 是同一个。 static const String officialDownloadPage = 'https://dl.ymaikj.com'; /// 推荐更新的「稍后提醒」:记住被推迟的版本号与时刻,同一版本 [_snoozeHours] 小时内冷启动不再弹。 /// 只作用于冷启动的自动检查;设置页手动「检查更新」不看它。强更从不受它影响。 static const String _snoozeKey = 'update_snooze'; static const int _snoozeHours = 24; void snoozeVersion(String version) { try { GetStorage().write(_snoozeKey, { 'version': version, 'ts': DateTime.now().millisecondsSinceEpoch, }); } catch (e) { _logger.w('记录稍后提醒失败: $e'); } } bool isSnoozed(String version) { try { final raw = GetStorage().read(_snoozeKey); if (raw is! Map) return false; if (raw['version']?.toString() != version) return false; final ts = raw['ts']; if (ts is! int) return false; return DateTime.now().millisecondsSinceEpoch - ts < Duration(hours: _snoozeHours).inMilliseconds; } catch (_) { return false; } } /// [bypassCache] 为 true 时跳过 10 分钟缓存真的发一次请求——设置页手动「检查更新」用, /// 否则刚发完版用户主动点检查可能拿到缓存里的「已是最新」。 Future checkForUpdate({bool bypassCache = false}) async { if (!bypassCache && _lastCheckTime != null && DateTime.now().difference(_lastCheckTime!).inMinutes < _cacheIntervalMinutes) { // [1]没有超过10分钟,返回缓存数据 _logger.d('没有超过10分钟,返回缓存数据'); return VersionUpdateInfo( hasUpdate: _hasUpdate ?? false, currentVersion: _appVersion ?? '1.0.0', latestVersion: (_latestVersion ?? '1.0.0').split('+')[0], appInfo: _appInfo, updateType: (_hasUpdate ?? false) ? ((_isForceUpdate ?? false) ? UpdateType.force : UpdateType.optional) : UpdateType.none, ); } final currentVersion = await getCurrentVersion(); try { final platformInfo = await getCurrentPlatformInfo(); _platform = platformInfo.platform; // 必须带上本机版本号:游客登录入口是否显示、是否需要强更,都由服务端按这个版本判定, // 不传服务端认不出是哪一版,后台限定了生效版本时会一律隐藏。 // 传净版本号(去掉 "+构建号"):后台填的是 1.0.2 这种商店版本号,两边口径必须一致。 // 直接传 "1.0.1+3" 虽然也能被服务端的 leadingInt 吃掉后缀而碰巧判对,但那是巧合不是约定。 final response = await Api.getChannelApp({ 'channel': platformInfo.channel.value, 'version': currentVersion.split('+')[0], }); _logger.d('lxm--获取渠道包信息(channel=${platformInfo.channel.name}):$response'); final AppInfo? app = _parseApp(response); // ⚠️ 缓存时间戳只在**请求成功之后**才写。早先是在发请求之前写的,于是一次网络抖动 // 就把结果锁死 10 分钟:失败分支把 _isShowTourists 置 false,而缓存命中分支 // 根本不重算它 —— 表现为 splash 那次失败之后,登录页怎么重进游客入口都不出现。 _lastCheckTime = DateTime.now(); if (app == null) { // 后台还没给这个渠道配任何东西。按「无更新 + 不显示游客入口」处理: // 游客入口判宽会在送审期把不该露的入口露给审核员,判严只是入口少显示。 _logger.w('lxm--该渠道未配置,按无更新处理'); _appInfo = null; _isShowTourists = false; showIdVerify.value = false; _allowSkipDeviceBinding = false; _hasUpdate = false; _isForceUpdate = false; _isReviewing = false; return VersionUpdateInfo( hasUpdate: false, currentVersion: currentVersion.split('+')[0], latestVersion: currentVersion.split('+')[0], updateType: UpdateType.none, ); } _appInfo = app; _latestVersion = app.version; _updateUrl = app.address; // 这里的 tourists 已是服务端按本机版本算好的结论,直接用,不要再做版本比较。 _isShowTourists = app.tourists; showIdVerify.value = app.idverify; _allowSkipDeviceBinding = app.allowskipdevicebinding; _logger.d('lxm--从服务器获取游客登录配置(最终): $_isShowTourists'); // 升级判定同样取服务端结论:版本号逐段比较各端各写一遍必然出现实现不一致, // 而强更判错的代价是全员无法使用。服务端没回这几个字段时按 false 处理。 _hasUpdate = _asBool(response, 'needupdate'); _isForceUpdate = _asBool(response, 'forceupdate'); _isReviewing = _asBool(response, 'reviewing'); final UpdateType updateType = _hasUpdate! ? (_isForceUpdate! ? UpdateType.force : UpdateType.optional) : UpdateType.none; return VersionUpdateInfo( hasUpdate: _hasUpdate!, currentVersion: currentVersion.split('+')[0], latestVersion: app.version.split('+')[0], appInfo: app, updateType: updateType, ); } catch (e) { _logger.e('检查版本更新失败: $e'); // 失败方向偏向「不显示游客入口 / 不提示更新」。注意**不写** _lastCheckTime: // 失败不该占用 10 分钟缓存窗口,下一次调用要能真的重试。 _isShowTourists = false; showIdVerify.value = false; _allowSkipDeviceBinding = false; _hasUpdate = false; _isForceUpdate = false; _isReviewing = false; return VersionUpdateInfo( hasUpdate: false, currentVersion: currentVersion.split('+')[0], latestVersion: currentVersion.split('+')[0], updateType: UpdateType.none, ); } } /// 从 `user_getchannelapp` 的响应里取 app 段;该渠道没配置时服务端回的是空对象。 AppInfo? _parseApp(dynamic response) { try { final appJson = (response is Map) ? response['app'] : null; if (appJson is Map) return AppInfo.fromJson(appJson); } catch (e) { _logger.e('解析渠道包信息失败: $e'); } return null; } /// 取响应里的布尔字段;缺字段或类型不对一律 false(服务端 omitempty 会吃掉 false)。 static bool _asBool(dynamic response, String key) { if (response is! Map) return false; final v = response[key]; if (v is bool) return v; if (v is num) return v != 0; if (v is String) return v == 'true' || v == '1'; return false; } /// 获取当前应用版本信息 Future getCurrentVersion() async { try { final packageInfo = await PackageInfo.fromPlatform(); _appVersion = packageInfo.version; _buildNumber = packageInfo.buildNumber; return '${packageInfo.version}+${packageInfo.buildNumber}'; } catch (e) { _logger.e('获取当前版本信息失败: $e'); return '1.0.0'; } } /// 获取当前平台信息 Future getCurrentPlatformInfo() async { if (Platform.isIOS) { _channel = UserChannel.GLOBAL_APPLE; await _getIosDeviceBrand(); return const PlatformInfo( platform: 'ios', storeType: AppStoreType.appStore, channel: UserChannel.GLOBAL_APPLE, ); } if (Platform.isAndroid) { // 根据设备品牌判断应用商店类型 return await getAndroidStoreInfoByBrand(); } return const PlatformInfo( platform: 'unknown', storeType: AppStoreType.unknown, channel: UserChannel.UNKNOWN, ); } /// 根据设备品牌获取应用商店信息 /// /// 该方法根据设备品牌判断应用商店类型,并返回对应的 [PlatformInfo] 对象。 /// 如果品牌不在预设列表中,将使用环境变量 [Channel] 或默认值 [UserChannel.GLOBAL_GOOGLE]。 Future getAndroidStoreInfoByBrand() async { try { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; final brand = androidInfo.brand.toLowerCase(); final manufacturer = androidInfo.manufacturer.toLowerCase(); _deviceBrand = brand; // 国内 5 家商店合成一个包之后,.env 里的 Channel 只是兜底:识别不出品牌 // 的设备(三星、魅族……)才用它,其余一律按设备品牌推导渠道。 String channelFromEnv = dotenv.env['Channel'] ?? 'OFFICIAL'; Logger().d('channelFromEnv: $channelFromEnv'); final envChannel = UserChannel.fromName(channelFromEnv); UserChannel brandChannel; //品牌渠道 // 海外包与官方包不参与品牌推导:Google 包装在华为手机上仍要连海外后端 // (baseUrl 只认 GLOBAL_GOOGLE),官方/内测包也要保住 OFFICIAL 才有的入口。 if (envChannel == UserChannel.GLOBAL_GOOGLE || envChannel == UserChannel.GLOBAL_APPLE || envChannel == UserChannel.OFFICIAL) { brandChannel = envChannel; } else { switch (brand) { case 'huawei': brandChannel = UserChannel.CHINA_HUAWEI; break; case 'xiaomi': case 'redmi': brandChannel = UserChannel.CHINA_XIAOMI; break; case 'oppo': case 'oneplus': brandChannel = UserChannel.CHINA_OPPO; break; case 'vivo': case 'iqoo': brandChannel = UserChannel.CHINA_VIVO; break; case 'honor': brandChannel = UserChannel.CHINA_HONOR; break; default: // brand 认不出时再看 manufacturer(老荣耀 brand=HONOR/厂商=HUAWEI, // 也有反过来的机型),仍认不出才回退到 .env 的兜底渠道。 if (manufacturer == 'huawei') { brandChannel = UserChannel.CHINA_HUAWEI; } else if (manufacturer == 'honor') { brandChannel = UserChannel.CHINA_HONOR; } else { brandChannel = envChannel; } break; } } // 渠道以品牌推导结果为准:单包多商店后,游客登录开关、渠道统计、 // 版本更新地址都走这一个值。 _channel = brandChannel; return PlatformInfo( platform: 'android', storeType: AppStoreType.official, channel: brandChannel, ); } catch (e) { _logger.e('获取Android设备信息失败: $e'); return const PlatformInfo( platform: 'android', storeType: AppStoreType.googlePlay, channel: UserChannel.UNKNOWN, ); } } /// 获取iOS设备品牌信息 Future _getIosDeviceBrand() async { try { final deviceInfo = DeviceInfoPlugin(); final iosInfo = await deviceInfo.iosInfo; // 获取设备型号信息 final model = iosInfo.model.toLowerCase(); // 例如: "iPhone", "iPad", "iPod touch" //print('lxm---model: $model'); final utsname = iosInfo.utsname; // 包含更详细的设备信息 //print('lxm---utsname: $utsname'); final machineId = utsname.machine.toLowerCase(); // 例如: "iPhone14,2" //print('lxm---machineId: $machineId'); // 根据设备型号确定品牌 if (model.contains('iphone') || machineId.contains('iphone')) { _deviceBrand = 'iphone'; } else if (model.contains('ipad') || machineId.contains('ipad')) { _deviceBrand = 'ipad'; } else if (model.contains('ipod') || machineId.contains('ipod')) { _deviceBrand = 'ipod'; } else if (model.contains('mac') || machineId.contains('mac')) { _deviceBrand = 'mac'; } else { _deviceBrand = 'ios'; // 默认值 } _logger.d('iOS设备品牌: $_deviceBrand (model: $model, machine: $machineId)'); } catch (e) { _logger.e('获取iOS设备品牌失败: $e'); _deviceBrand = 'ios'; // 默认值 } } /// 执行更新操作 Future performUpdate(VersionUpdateInfo updateInfo) async { //print('lxm---updateInfo详情:'); //print('lxm---hasUpdate: ${updateInfo.hasUpdate}'); //print('lxm---currentVersion: ${updateInfo.currentVersion}'); //print('lxm---latestVersion: ${updateInfo.latestVersion}'); //print('lxm---updateType: ${updateInfo.updateType}'); //print('lxm---appInfo: ${updateInfo.appInfo?.toJson()}'); if (!updateInfo.hasUpdate || updateInfo.appInfo == null) { return false; } try { final address = updateInfo.appInfo!.address ?? ''; _logger.d('lxm---执行更新操作,下载地址: $address'); if (address.isNotEmpty && await _openDownloadUrl(address)) return true; // Android 兜底:服务端给的地址打不开(商店未装 / 链接失效)就去网页下载页, // 它按品牌跳商店、认不出就直接给安装包。iOS 不兜底——那页面给的是 apk。 if (Platform.isAndroid) { _logger.w('下载地址打不开,回退网页下载页: $officialDownloadPage'); return await _openDownloadUrl(officialDownloadPage); } return false; } catch (e) { _logger.e('执行更新失败: $e'); return false; } } /// 打开下载链接 Future _openDownloadUrl(String downloadUrl) async { return await _launchUrl(downloadUrl); } /// 启动URL Future _launchUrl(String url) async { try { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { return await launchUrl(uri, mode: LaunchMode.externalApplication); } return false; } catch (e) { _logger.e('启动URL失败: $e'); return false; } } }