This commit is contained in:
2026-08-25 16:52:55 +08:00
parent d3ba1c7e48
commit e8812a0a73
34 changed files with 1072 additions and 45 deletions
+18
View File
@@ -101,3 +101,21 @@
- `priceCents` 为整数分,客户端展示 ÷100 转元
- 展示名由客户端按 `days` 派生「N天」,接口无 label 字段
- 后端套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置重启生效)
## 7. 版本更新检查
`GET /api/v1/app/update`(**公开接口,无需登录**)在 App 启动时调用(协议层见 `lib/update/update_checker.dart`)。
响应 `data`(无记录时字段为空串,视为无需更新):
```json
{
"version": "1.1.0",
"notes": "修复识别准确率问题"
}
```
- **仅 Android 调用**`UpdateChecker.fetch()` 在非 Android 平台直接返回空(iOS 不做版本下发,用户从 App Store 自行更新)
- 客户端以「语义化版本号」按数字段比较(`1.10.0 > 1.9.9`),**服务器版本 > 已装版本即强制更新**:弹全屏阻塞页(禁返回,仅「立即更新」),`url_launcher` 打开系统浏览器下载固定地址 `{apiBaseUrl}/download/observer-latest.apk`(后端静态托管,永远是最新 APK
- **双版本比较防反复提示**:用户点「立即更新」时把服务器版本号写入本地(`SessionStore.accepted_update_version`);判定条件是服务器版本 > 已装版本 **或** 服务器版本 > 已接受版本。APK 版本号不递增(每次打的包 versionName 相同)时,已更新完成的手机重启也不会再次弹更新
- 网络异常/响应异常时静默跳过检查,不阻塞启动
+24
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'auth/auth_screen.dart';
@@ -8,6 +9,8 @@ import 'container.dart';
import 'home/home_screen.dart';
import 'legal/terms_screen.dart';
import 'payment/paywall_screen.dart';
import 'update/update_checker.dart';
import 'update/update_screen.dart';
class ObserverApp extends StatelessWidget {
final AppContainer container;
@@ -66,7 +69,28 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
final info = await UpdateChecker().fetch();
final current = await PackageInfo.fromPlatform();
if (!mounted) return;
final session = context.read<SessionStore>();
final acceptedUpdate = await session.readAcceptedUpdateVersion();
if (!mounted) return;
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => UpdateScreen(
version: info.version,
url: UpdateChecker.downloadUrl(),
notes: info.notes,
onUpdateAccepted: () =>
session.saveAcceptedUpdateVersion(info.version),
),
));
return;
}
final token = await session.readToken();
if (!mounted) return;
Navigator.of(context)
+16
View File
@@ -2,13 +2,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static const _acceptedUpdateKey = 'accepted_update_version';
static String? _cachedToken;
static String? _cachedPhone;
static String? _cachedAcceptedUpdate;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
@@ -20,6 +24,18 @@ class SessionStore {
return _cachedPhone = await _storage.read(key: _phoneKey);
}
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
Future<String> readAcceptedUpdateVersion() async {
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
return _cachedAcceptedUpdate =
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
}
Future<void> saveAcceptedUpdateVersion(String version) async {
_cachedAcceptedUpdate = version;
await _storage.write(key: _acceptedUpdateKey, value: version);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
/// App 版本更新信息(GET /api/v1/app/update 响应 data;无记录时为空信息)
class AppUpdateInfo {
final String version;
final String notes;
const AppUpdateInfo({required this.version, required this.notes});
/// 服务器无版本记录时返回空信息,调用方视为无需更新
bool get isEmpty => version.isEmpty;
}
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
/// (无普通/强制之分)。
class UpdateChecker {
final String baseUrl;
final http.Client _client;
UpdateChecker({String? baseUrl, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client();
/// 下载地址:固定静态路径(管理端上传的 APK 覆盖保存为固定文件)
static String downloadUrl({String? baseUrl}) =>
'${baseUrl ?? AppConfig.apiBaseUrl}/download/observer-latest.apk';
/// 拉取服务器最新版本;非 Android 或网络异常时返回空信息,不阻塞启动
Future<AppUpdateInfo> fetch() async {
if (!Platform.isAndroid) {
return const AppUpdateInfo(version: '', notes: '');
}
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 8));
final body = jsonDecode(res.body) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
return AppUpdateInfo(
version: data['version'] as String? ?? '',
notes: data['notes'] as String? ?? '',
);
} catch (_) {
return const AppUpdateInfo(version: '', notes: '');
}
}
/// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
/// 已更新完成再次启动也不会反复提示。
static bool needsUpdate(String server, String installed, String accepted) {
if (server.isEmpty || installed.isEmpty) return false;
return isNewer(server, installed) || isNewer(server, accepted);
}
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
/// 任一版本号解析失败时视为相等(不触发更新)。
static bool isNewer(String a, String b) {
final pa = _parse(a);
final pb = _parse(b);
if (pa == null || pb == null) return false;
for (var i = 0; i < 3; i++) {
if (pa[i] != pb[i]) return pa[i] > pb[i];
}
return false;
}
static List<int>? _parse(String v) {
final parts = v.split('.');
if (parts.length != 3) return null;
final nums = <int>[];
for (final p in parts) {
final n = int.tryParse(p);
if (n == null) return null;
nums.add(n);
}
return nums;
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出),
/// 仅「立即更新」跳转系统浏览器下载 APK。
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
class UpdateScreen extends StatelessWidget {
final String version;
final String url;
final String notes;
final VoidCallback? onUpdateAccepted;
const UpdateScreen({
super.key,
required this.version,
required this.url,
this.notes = '',
this.onUpdateAccepted,
});
Future<void> _launch() async {
onUpdateAccepted?.call();
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// 跳转失败保持页面,用户可重试
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: false,
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.system_update_alt,
size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text('发现新版本 $version',
style: theme.textTheme.headlineSmall),
const SizedBox(height: 12),
if (notes.isNotEmpty)
Text(notes,
textAlign: TextAlign.center,
style: const TextStyle(height: 1.6)),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _launch,
icon: const Icon(Icons.download),
label: const Text('立即更新'),
style: FilledButton.styleFrom(
minimumSize: const Size(200, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 12),
Text('不更新将无法继续使用',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
],
),
),
),
),
);
}
}
+65 -1
View File
@@ -497,7 +497,7 @@ packages:
source: hosted
version: "3.0.0"
package_info_plus:
dependency: transitive
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
@@ -765,6 +765,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
+2
View File
@@ -27,6 +27,8 @@ dependencies:
http: ^1.2.0
cupertino_http: ^3.0.2
wakelock_plus: ^1.2.0
package_info_plus: ^9.0.1
url_launcher: ^6.3.2
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains