From 82800373cafaf920c0507577a1f6470e1eac5ff0 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 29 Oct 2026 14:26:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E5=9B=BE=E7=89=87=E8=AE=BF=E9=97=AE=20fix:?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- lib/common/api/app_api.dart | 19 ++ lib/common/utils/app/app_file_tool.dart | 13 +- lib/common/utils/app/app_network_tool.dart | 69 ++--- lib/common/utils/app/file_access.dart | 288 ++++++++++++++++++ lib/common/utils/app/package_download.dart | 26 ++ .../utils/sync/terminal_auto_update_tool.dart | 15 +- lib/common/values/constants.dart | 5 + lib/common/widgets/app/app_img.dart | 56 +++- .../widgets/order/select_pay_type_dialog.dart | 16 +- lib/main.dart | 3 + .../widgets/setting-version-setting.dart | 24 +- lib/sub_screen/page/customer_display.dart | 19 +- test-vectors/file-access.json | 29 ++ test/file_access_test.dart | 93 ++++++ test/package_download_test.dart | 52 ++++ 16 files changed, 628 insertions(+), 104 deletions(-) create mode 100644 lib/common/utils/app/file_access.dart create mode 100644 lib/common/utils/app/package_download.dart create mode 100644 test-vectors/file-access.json create mode 100644 test/file_access_test.dart create mode 100644 test/package_download_test.dart diff --git a/.gitignore b/.gitignore index f32effb..3acac09 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,7 @@ app.*.map.json .v2c/ .video_agent/ .mimosa/ -.zcode/ \ No newline at end of file +.zcode/ + +# Local file-access credentials +.file-access/ diff --git a/lib/common/api/app_api.dart b/lib/common/api/app_api.dart index 66bd2b4..246dfc3 100644 --- a/lib/common/api/app_api.dart +++ b/lib/common/api/app_api.dart @@ -23,4 +23,23 @@ abstract class AppApi { if (res.data == null) return null; return ServiceAppVersionPage.fromJson(res.data); } + /// Requery the same published record; never switch APK versions during a retry. + static Future refreshPackageUrl(ServiceAppVersion version) async { + if (version.id == null) throw StateError('缺少版本标识,请刷新版本列表'); + final page = await openCardList({ + 'id': version.id, + 'appUniqueCode': version.appUniqueCode, + 'platform': version.platform, + 'current': 1, + 'size': 1, + }, silent: true); + for (final item in page?.records ?? []) { + if (item.id == version.id && item.versionCode == version.versionCode && + item.appUniqueCode == version.appUniqueCode && item.platform == version.platform) { + return item.downloadUrl ?? ''; + } + } + throw StateError('该版本已下架,请刷新版本列表'); + } + } diff --git a/lib/common/utils/app/app_file_tool.dart b/lib/common/utils/app/app_file_tool.dart index d2280b0..d0c7abe 100644 --- a/lib/common/utils/app/app_file_tool.dart +++ b/lib/common/utils/app/app_file_tool.dart @@ -161,14 +161,9 @@ abstract class AppFileTool { // 获取文件后缀 static String getFileExtension(String url) { - int lastDotIndex = url.lastIndexOf('.'); - if (lastDotIndex == -1) { - return ''; - } - int lastSlashIndex = url.lastIndexOf('/'); - if (lastDotIndex > lastSlashIndex) { - return url.substring(lastDotIndex); - } - return ''; + final uri = Uri.tryParse(url); + final name = uri?.queryParameters['fileName'] ?? uri?.path ?? url; + final dot = name.lastIndexOf('.'); + return dot > name.lastIndexOf('/') ? name.substring(dot).toLowerCase() : ''; } } diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index f26cb06..8e64994 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -1,3 +1,4 @@ +import 'file_access.dart'; import 'dart:async'; import 'dart:typed_data'; @@ -13,9 +14,6 @@ import 'package:pj_supermarket_cloud/common/index.dart'; abstract class AppNetworkTool { static const _tag = "网络工具类"; - /// 文件中心对外读取 OSS 对象的统一网关路径。 - static const _ossFilePath = '/admin/sys-file/oss/file'; - static StreamSubscription>? subscription; static bool isNetworkOssImage(String imagePath) { @@ -25,7 +23,7 @@ abstract class AppNetworkTool { // 是否是网络图片 static bool isNetworkImage(String imagePath) { - if (isNetworkOssImage(imagePath)) return true; + if (FileAccess.client.isProtected(imagePath) || isNetworkOssImage(imagePath)) return true; final value = imagePath.trim(); if (value.startsWith('//')) return true; final uri = Uri.tryParse(value); @@ -93,7 +91,10 @@ abstract class AppNetworkTool { app.isNetwork.value = connected; EasyLoading.dismiss(); logger.w("|$_tag|监听网络变化,当前网络已经${connected ? '连接' : '断开'}"); - if (connected) unawaited(_restoreNetworkServices()); + if (connected) { + unawaited(FileAccess.refreshClock(force: true)); + unawaited(_restoreNetworkServices()); + } } /// 网络恢复后的消息连接和离线订单同步允许失败,不能产生未处理异常中断应用。 @@ -124,17 +125,8 @@ abstract class AppNetworkTool { /// 从网络中获取图片 static Future loadImageFromNetwork(String url) async { try { - final networkImage = serverImageUrl(url); - final response = await http.get(Uri.parse(networkImage)); - if (response.statusCode == 200) { - final Uint8List imageData = response.bodyBytes; - final decodeImage = imglib.decodeImage(imageData); - return decodeImage; - } else { - logger.e( - '|$_tag|从网络中获取图片statusCode错误statusCode:|${response.statusCode}|'); - return null; - } + final bytes = await downloadImage(url); + return bytes == null ? null : imglib.decodeImage(bytes); } catch (e) { logger.e('|$_tag|从网络中获取图片错误:|$e|'); return null; @@ -144,19 +136,17 @@ abstract class AppNetworkTool { // 从网络中下载图片 static Future downloadImage(String url, {http.Client? client}) async { - final networkImage = serverImageUrl(url); final downloadClient = client ?? http.Client(); try { - final request = http.Request('GET', Uri.parse(networkImage)); - return await (() async { - final response = await downloadClient.send(request); - if (response.statusCode != 200) { - await response.stream.drain(); - return null; - } - return response.stream.toBytes(); - })() - .timeout(const Duration(seconds: 30)); + for (var attempt = 0; attempt < 2; attempt++) { + final request = http.Request('GET', Uri.parse(FileAccess.client.url(url, force: attempt > 0))); + final response = await downloadClient.send(request).timeout(const Duration(seconds: 30)); + if (response.statusCode == 200) return await response.stream.toBytes().timeout(const Duration(seconds: 30)); + await response.stream.drain(); + if (response.statusCode != 403 || attempt > 0 || !FileAccess.client.isProtected(url)) return null; + await FileAccess.refreshClock(force: true); + } + return null; } catch (e) { logger.w('|$_tag|图片下载失败|error:${e.runtimeType}|'); return null; @@ -170,30 +160,7 @@ abstract class AppNetworkTool { /// 完整 HTTP(S) 地址保持不变;文件中心相对地址拼到 [Constants.ossUrl] /// 配置的部署前缀后;只有 OSS 对象名时,通过标准文件读取接口访问。 static String serverImageUrl(String src) { - final value = src.trim(); - if (value.isEmpty) return value; - - final sourceUri = Uri.tryParse(value); - if (sourceUri != null && - (sourceUri.scheme == 'http' || sourceUri.scheme == 'https') && - sourceUri.host.isNotEmpty) { - return value; - } - - final baseUri = Uri.parse(Constants.ossUrl); - final origin = baseUri.origin; - final base = Constants.ossUrl.replaceFirst(RegExp(r'/+$'), ''); - if (value.startsWith('//')) return '${baseUri.scheme}:$value'; - if (value.startsWith('/api/')) return '$origin$value'; - if (value.startsWith('api/')) return '$origin/$value'; - if (value.startsWith('/admin/')) return '$base$value'; - if (value.startsWith('admin/')) return '$base/$value'; - - final endpoint = Uri.parse('$base$_ossFilePath'); - if (value.startsWith('?')) { - return endpoint.replace(query: value.substring(1)).toString(); - } - return endpoint.replace(queryParameters: {'fileName': value}).toString(); + return FileAccess.client.url(src); } /// 兼容既有调用名称,所有 OSS 图片地址统一委托给全局解析方法。 diff --git a/lib/common/utils/app/file_access.dart b/lib/common/utils/app/file_access.dart new file mode 100644 index 0000000..88bec5e --- /dev/null +++ b/lib/common/utils/app/file_access.dart @@ -0,0 +1,288 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; +import '../../values/constants.dart'; + +const fileAccessFields = {'appId', 'kid', 'ts', 'sign'}; +const _filePaths = { + '/sys-file/oss/file', + '/admin/sys-file/oss/file', + '/api/admin/sys-file/oss/file' +}; + +String fileAccessCanonical(String file, String app, String kid, int ts) => [ + 'v1', + 'oss-file-read', + app, + kid, + '$ts', + base64Url.encode(utf8.encode(file)).replaceAll('=', '') + ].join('\n'); +String fileAccessSignature( + String file, String app, String kid, int ts, String key) { + final bytes = base64Decode(key); + if (bytes.length < 32) { + throw StateError('File access key must contain at least 32 bytes'); + } + return Hmac(sha256, bytes) + .convert(utf8.encode(fileAccessCanonical(file, app, kid, ts))) + .toString(); +} + +/// Pure URL/signature logic. No GetX, database, device or main-engine dependency. +class FileAccessClient { + FileAccessClient( + {required this.baseUrl, + required this.appId, + required this.kid, + required this.key, + this.allowedOrigins = const [], + DateTime Function()? now}) + : now = now ?? DateTime.now; + final String baseUrl, appId, kid, key; + final List allowedOrigins; + final DateTime Function() now; + int offsetMs = 0, ttlSeconds = 1800; + final _cache = {}; + + String resolve(String source) { + final value = source.trim(); + if (value.isEmpty) return value; + final uri = Uri.tryParse(value); + if (uri != null && uri.hasScheme) return value; + if (value.startsWith('assets/') || + value.startsWith('/static/') || + value.startsWith('static/')) { + return value; + } + final base = Uri.parse(baseUrl); + final root = baseUrl.replaceFirst(RegExp(r'/+$'), ''); + if (value.startsWith('//')) return '${base.scheme}:$value'; + if (RegExp(r'^(?:/api)?/admin/sys-file/oss/(?:file|access-info)(?:[?#]|$)') + .hasMatch(value)) { + return '$root${value.replaceFirst(RegExp(r'^/api/'), '/')}'; + } + final prefix = base.path.replaceFirst(RegExp(r'/+$'), ''); + if (prefix.isNotEmpty && value.startsWith('$prefix/')) { + return '${base.origin}$value'; + } + if (value.startsWith('/api/')) return '${base.origin}$value'; + if (value.startsWith('api/')) return '${base.origin}/$value'; + if (value.startsWith('/admin/')) return '$root$value'; + if (value.startsWith('admin/')) return '$root/$value'; + if (value.startsWith('/sys-file/oss/')) return '$root/admin$value'; + final endpoint = Uri.parse('$root/admin/sys-file/oss/file'); + if (value.startsWith('?')) { + return endpoint.replace(query: value.substring(1)).toString(); + } + if (value.contains('/') || value.contains('\\')) return value; + return endpoint.replace(queryParameters: {'fileName': value}).toString(); + } + + Uri? _owned(String source) { + final uri = Uri.tryParse(resolve(source)); + if (uri == null || + !['http', 'https'].contains(uri.scheme) || + uri.userInfo.isNotEmpty || + (!_filePaths.contains(uri.path) && + uri.path != + '${Uri.parse(baseUrl).path.replaceFirst(RegExp(r'/+$'), '')}/admin/sys-file/oss/file')) { + return null; + } + final origins = [ + Uri.parse(baseUrl).origin, + ...allowedOrigins + .where((e) => e.isNotEmpty) + .map((e) => Uri.parse(e).origin) + ]; + return origins.contains(uri.origin) ? uri : null; + } + + bool isProtected(String source) => _owned(source) != null; + String original(String source) { + final uri = _owned(source); + if (uri == null) return resolve(source); + final params = Map>.from(uri.queryParametersAll) + ..removeWhere((key, _) => fileAccessFields.contains(key)); + final names = params.remove('fileName'); + final ordered = { + for (final key in params.keys.toList()..sort()) key: params[key]!, + if (names != null) 'fileName': names + }; + return uri.replace(queryParameters: ordered).toString(); + } + + String url(String source, {bool force = false}) { + final uri = _owned(source); + if (uri == null) return resolve(source); + final names = uri.queryParametersAll['fileName']; + if (names == null || + names.length != 1 || + names.single.isEmpty || + names.single.contains(RegExp(r'[\r\n]'))) { + throw const FormatException('Invalid file-center URL'); + } + final raw = original(source), + seconds = (now().millisecondsSinceEpoch + offsetMs) ~/ 1000; + final cached = _cache[raw]; + if (!force && + cached != null && + seconds >= cached.ts - 300 && + seconds < cached.ts + ttlSeconds - 300) { + return cached.url; + } + final params = Map>.from(uri.queryParametersAll) + ..removeWhere( + (key, _) => fileAccessFields.contains(key) || key == 'fileName'); + params.addAll({ + 'appId': [appId], + 'kid': [kid], + 'ts': ['$seconds'], + 'sign': [fileAccessSignature(names.single, appId, kid, seconds, key)], + 'fileName': names + }); + final signed = uri.replace(queryParameters: params).toString(); + if (_cache.length >= 2048) _cache.remove(_cache.keys.first); + _cache[raw] = (ts: seconds, url: signed); + return signed; + } + + void synchronize(int serverTime, int ttl, int roundTripMs) { + offsetMs = serverTime + roundTripMs ~/ 2 - now().millisecondsSinceEpoch; + if (ttl > 300 && ttl <= 86400) ttlSeconds = ttl; + _cache.clear(); + } + + void clear() => _cache.clear(); +} + +abstract class FileAccess { + static final client = FileAccessClient( + baseUrl: Constants.ossUrl, + appId: Constants.fileAccessAppId, + kid: Constants.fileAccessKeyId, + key: Constants.fileAccessKey, + allowedOrigins: Constants.fileAccessAllowedOrigins.split(',')); + static final revision = ValueNotifier(0); + static Timer? _timer; + static Future? _pending; + static bool _initialized = false; + static StreamSubscription>? _connectivity; + static DateTime? _lastSync; + static final _retries = {}; + static final _lifecycle = _FileAccessLifecycle(); + + static void initialize() { + if (_initialized) return; + if (Constants.fileAccessAppId != 'flutter' || + !RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(Constants.fileAccessKeyId) || + base64Decode(Constants.fileAccessKey).length < 32) { + throw StateError( + 'Build with --dart-define-from-file=.file-access/config.json'); + } + _initialized = true; + WidgetsBinding.instance.addObserver(_lifecycle); + _connectivity ??= Connectivity().onConnectivityChanged.listen((results) { + if (results.any((result) => result != ConnectivityResult.none)) { + _retries.clear(); + unawaited(refreshClock(force: true)); + } + }, onError: (Object _) {}); + resume(); + } + + static void resume() { + _timer ??= + Timer.periodic(const Duration(minutes: 1), (_) => revision.value++); + unawaited(refreshClock()); + } + + static void pause() { + _timer?.cancel(); + _timer = null; + } + + static Future refreshClock({bool force = false}) { + if (_pending != null) return _pending!; + if (!force && + _lastSync != null && + DateTime.now().difference(_lastSync!) < const Duration(minutes: 1)) { + return Future.value(); + } + return _pending = _readClock().whenComplete(() { + _pending = null; + revision.value++; + }); + } + + static Future _readClock() async { + final connection = http.Client(), + started = DateTime.now().millisecondsSinceEpoch; + try { + final response = await connection + .get(Uri.parse('${Constants.ossUrl}/admin/sys-file/oss/access-info')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return; + final data = jsonDecode(response.body)['data']; + if (data is Map && data['serverTime'] is num) { + client.synchronize( + (data['serverTime'] as num).toInt(), + (data['ttlSeconds'] as num?)?.toInt() ?? 1800, + DateTime.now().millisecondsSinceEpoch - started); + _lastSync = DateTime.now(); + } + } catch (_) { + /* Keep the previous offset. A clock failure never enables unsigned access. */ + } finally { + connection.close(); + } + } + + static Future retryImage(String source) async { + if (!client.isProtected(source)) return false; + final raw = client.original(source), + previous = _retries[client.original(source)]; + if (previous != null && + DateTime.now().difference(previous) < const Duration(minutes: 30)) { + return false; + } + if (_retries.length >= 2048) _retries.clear(); + _retries[raw] = DateTime.now(); + final previousUrl = client.url(source); + await refreshClock(force: true); + client.clear(); + if (client.url(source) == previousUrl) { + await Future.delayed(const Duration(milliseconds: 1100)); + client.clear(); + } + revision.value++; + return true; + } + + /// Keep server-issued package links (24h); ordinary images use the Flutter key (30m). + static String packageUrl(String source) { + final uri = Uri.tryParse(client.resolve(source)); + if (uri != null && + uri.queryParameters['appId'] == 'package' && + uri.queryParameters.containsKey('sign')) { + return uri.toString(); + } + return client.url(source); + } +} + +class _FileAccessLifecycle extends WidgetsBindingObserver { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + FileAccess._retries.clear(); + FileAccess.resume(); + } else if (state == AppLifecycleState.paused || + state == AppLifecycleState.detached) { + FileAccess.pause(); + } + } +} diff --git a/lib/common/utils/app/package_download.dart b/lib/common/utils/app/package_download.dart new file mode 100644 index 0000000..1de2e12 --- /dev/null +++ b/lib/common/utils/app/package_download.dart @@ -0,0 +1,26 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'file_access.dart'; + +/// Native downloads do not have a Dart UA. Refresh the exact version once on 403. +Future downloadPackageWithRefresh({ + required DownloadTask task, + required Future Function(DownloadTask) download, + required Future Function() refreshUrl, + bool Function(String)? isProtected, +}) async { + final result = await download(task); + final error = result.exception; + if (result.status != TaskStatus.failed || + error is! TaskHttpException || + error.httpResponseCode != 403 || + !(isProtected ?? FileAccess.client.isProtected)(task.url)) { + return result; + } + final fresh = await refreshUrl(); + if (fresh.isEmpty) throw StateError('该版本已下架或下载地址不可用,请刷新版本列表'); + return download(task.copyWith( + url: FileAccess.packageUrl(fresh), + taskId: '${task.taskId}_file_retry', + retries: 0, + )); +} diff --git a/lib/common/utils/sync/terminal_auto_update_tool.dart b/lib/common/utils/sync/terminal_auto_update_tool.dart index 4b1c7b4..d3338bc 100644 --- a/lib/common/utils/sync/terminal_auto_update_tool.dart +++ b/lib/common/utils/sync/terminal_auto_update_tool.dart @@ -1,3 +1,5 @@ +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/package_download.dart'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -121,8 +123,11 @@ abstract final class TerminalAutoUpdateTool { try { // 进度回调频率由下载插件控制,按 10% 节点限流后写日志,避免大 APK 刷屏。 var nextProgressToLog = 0; - final download = await FileDownloader().download( - task, + final download = await downloadPackageWithRefresh( + task: task, + refreshUrl: () => AppApi.refreshPackageUrl(target), + download: (currentTask) => FileDownloader().download( + currentTask, onProgress: (progress) { if (!progress.isFinite || progress < 0 || progress > 1) return; final percentage = (progress * 100).floor().clamp(0, 100).toInt(); @@ -133,6 +138,7 @@ abstract final class TerminalAutoUpdateTool { nextProgressToLog += 10; } }, + ), ); if (download.status != TaskStatus.complete || !await apkFile.exists()) { throw StateError('APK 下载失败(${download.status.name})'); @@ -275,9 +281,6 @@ abstract final class TerminalAutoUpdateTool { static String? _resolveDownloadUrl(String? source) { final value = source?.trim(); if (value == null || value.isEmpty) return null; - if (value.startsWith('http://') || value.startsWith('https://')) { - return value; - } - return '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$value'; + return FileAccess.packageUrl(value); } } diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 3fc418d..e7d5b54 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -9,6 +9,11 @@ enum LoginVersion { } class Constants { + static const fileAccessAppId = String.fromEnvironment('FILE_ACCESS_APP_ID', defaultValue: 'flutter'); + static const fileAccessKeyId = String.fromEnvironment('FILE_ACCESS_KEY_ID', defaultValue: 'v1'); + static const fileAccessKey = String.fromEnvironment('FILE_ACCESS_KEY'); + static const fileAccessAllowedOrigins = String.fromEnvironment('FILE_ACCESS_ALLOWED_ORIGINS'); + /// 生产模式写入文件的最低日志等级,达到该等级及以上的日志才会持久化。 static const productionFileLogLevel = Level.warning; diff --git a/lib/common/widgets/app/app_img.dart b/lib/common/widgets/app/app_img.dart index 8060a41..27a65d3 100644 --- a/lib/common/widgets/app/app_img.dart +++ b/lib/common/widgets/app/app_img.dart @@ -1,3 +1,4 @@ +import '../../utils/app/file_access.dart'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; @@ -32,8 +33,8 @@ Widget appNetworkImg( return SizedBox( width: imgWidth, height: imgHeight, - child: CachedNetworkImage( - imageUrl: AppNetworkTool.ossImage(src), + child: SignedNetworkImage( + imageUrl: src, fit: fit, // 网格卡片只需小图;约束解码宽度避免 1000px+ 源图按原尺寸解码进内存。 memCacheWidth: memCacheWidth, @@ -108,3 +109,54 @@ int? _decodeWidthBound(double width, double height) { final pixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? 2.0; return (logical * pixelRatio).round(); } + +/// Presentation URLs expire; the persistent cache identity remains the source URL. +class SignedNetworkImage extends StatefulWidget { + const SignedNetworkImage({super.key, required this.imageUrl, this.fit, this.width, this.height, + this.memCacheWidth, this.placeholder, this.errorWidget}); + final String imageUrl; + final BoxFit? fit; + final double? width, height; + final int? memCacheWidth; + final Widget Function(BuildContext, String)? placeholder; + final Widget Function(BuildContext, String, Object)? errorWidget; + @override + State createState() => _SignedNetworkImageState(); +} + +class _SignedNetworkImageState extends State { + int _retryGeneration = 0; + bool _retryScheduled = false; + + void _retry() { + if (_retryScheduled) return; + _retryScheduled = true; + final source = widget.imageUrl; + WidgetsBinding.instance.addPostFrameCallback((_) async { + final retried = await FileAccess.retryImage(source); + if (!mounted) return; + _retryScheduled = false; + if (retried && widget.imageUrl == source) { + // A stable cacheKey makes image providers compare equal. Remount only the + // failed image so a renewed URL is actually requested; retain disk cache. + setState(() => _retryGeneration++); + } + }); + } + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: FileAccess.revision, + builder: (context, _, __) => CachedNetworkImage( + key: ValueKey('${FileAccess.client.original(widget.imageUrl)}:$_retryGeneration'), + imageUrl: FileAccess.client.url(widget.imageUrl), + cacheKey: FileAccess.client.original(widget.imageUrl), + width: widget.width, height: widget.height, fit: widget.fit, memCacheWidth: widget.memCacheWidth, + placeholder: widget.placeholder, + errorWidget: (context, url, error) { + _retry(); + return widget.errorWidget?.call(context, url, error) ?? const SizedBox.shrink(); + }, + ), + ); +} diff --git a/lib/common/widgets/order/select_pay_type_dialog.dart b/lib/common/widgets/order/select_pay_type_dialog.dart index 5c641bc..b1350d0 100644 --- a/lib/common/widgets/order/select_pay_type_dialog.dart +++ b/lib/common/widgets/order/select_pay_type_dialog.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; @@ -2333,8 +2332,8 @@ class _CheckoutProductImage extends StatelessWidget { width: width, height: height, child: value?.isNotEmpty == true - ? CachedNetworkImage( - imageUrl: _checkoutImageUrl(value!), + ? SignedNetworkImage( + imageUrl: value!, fit: BoxFit.cover, placeholder: (_, __) => placeholder, errorWidget: (_, __, ___) => placeholder, @@ -2346,12 +2345,7 @@ class _CheckoutProductImage extends StatelessWidget { } } -/// 将完整网络地址或 OSS 相对路径统一转换为可加载的结算图片地址。 -String _checkoutImageUrl(String source) { - return RegExp(r'^https?://', caseSensitive: false).hasMatch(source) - ? source - : AppNetworkTool.ossImage(source); -} + /// 原价、优惠、额外支付和应付金额汇总。 class _CheckoutAmountSummary extends StatelessWidget { @@ -3015,8 +3009,8 @@ class _RecognizedUserAvatar extends StatelessWidget { width: 64.w, height: 64.w, child: source?.isNotEmpty == true - ? CachedNetworkImage( - imageUrl: _checkoutImageUrl(source!), + ? SignedNetworkImage( + imageUrl: source!, fit: BoxFit.cover, placeholder: (_, __) => placeholder, errorWidget: (_, __, ___) => placeholder, diff --git a/lib/main.dart b/lib/main.dart index 5f405ff..323e997 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; import 'dart:async'; import 'dart:ui'; @@ -19,6 +20,7 @@ Future checkSubScreen() async { case "/": WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); + FileAccess.initialize(); await AppRuntimeTool.initializePlatform(); final appTheme = Get.put(AppThemeControl()); // 全局餐段状态供商品管理和取餐广播任务读取。 @@ -49,6 +51,7 @@ Future checkSubScreen() async { case "subMain": WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); + FileAccess.initialize(); bool isMultipleScreen = await SubScreenPlugin.isMultipleScreen; bool isOverlay = await SubScreenPlugin.checkOverlayPermission; if (isMultipleScreen && isOverlay) { diff --git a/lib/pages/setting/widgets/setting-version-setting.dart b/lib/pages/setting/widgets/setting-version-setting.dart index c5f7f4e..752bfab 100644 --- a/lib/pages/setting/widgets/setting-version-setting.dart +++ b/lib/pages/setting/widgets/setting-version-setting.dart @@ -1,3 +1,5 @@ +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/package_download.dart'; import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -99,20 +101,18 @@ class VersionManageController extends GetxController { context, item: item, currentVersionCode: currentVersionCode.value, - onDownload: () => doDownload(item.downloadUrl ?? ''), + onDownload: () => doDownload(item), ); } /// 人工下载继续保存到系统下载目录,不参与 Root 自动安装流程。 - Future doDownload(String url) async { + Future doDownload(ServiceAppVersion version) async { + final url = version.downloadUrl ?? ''; if (url.isEmpty) { Toast.error(msg: '当前版本没有可用下载地址'); return; } - var downUrl = url; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - downUrl = '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$url'; - } + final downUrl = FileAccess.packageUrl(url); if (!Platform.isAndroid) { EasyLoading.showInfo('当前平台不支持系统下载'); return; @@ -136,13 +136,17 @@ class VersionManageController extends GetxController { updates: Updates.statusAndProgress, baseDirectory: BaseDirectory.applicationDocuments, ); - final result = await FileDownloader().download( - task, + final result = await downloadPackageWithRefresh( + task: task, + refreshUrl: () => AppApi.refreshPackageUrl(version), + download: (currentTask) => FileDownloader().download( + currentTask, onProgress: (progress) => EasyLoading.showProgress( progress, status: '(${(progress * 100).toStringAsFixed(0)}%) 下载中...', ), onStatus: (status) => logger.d('|$_tag|下载状态:$status'), + ), ); if (result.status != TaskStatus.complete) { EasyLoading.showError('下载失败'); @@ -161,8 +165,8 @@ class VersionManageController extends GetxController { } logger.i('|$_tag|人工下载完成:$movedPath'); EasyLoading.showSuccess('下载完成'); - } catch (error, stack) { - logger.e('|$_tag|下载失败error:|$error|stack:|$stack|'); + } catch (error) { + logger.e('|$_tag|下载失败type:|${error.runtimeType}|'); EasyLoading.showError('下载失败'); } finally { EasyLoading.dismiss(); diff --git a/lib/sub_screen/page/customer_display.dart b/lib/sub_screen/page/customer_display.dart index 6ddba2f..0caa154 100644 --- a/lib/sub_screen/page/customer_display.dart +++ b/lib/sub_screen/page/customer_display.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:pj_supermarket_cloud/common/style/xfj_design_tokens.dart'; -import 'package:pj_supermarket_cloud/common/values/constants.dart'; +import 'package:pj_supermarket_cloud/common/widgets/app/app_img.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/models/customer_display_models.dart'; import 'package:pj_supermarket_cloud/sub_screen/common/utils/customer_display_coordinator.dart'; import 'package:pj_supermarket_cloud/sub_screen/page/customer_welcome.dart'; @@ -396,12 +396,11 @@ class _ProductImage extends StatelessWidget { height: imageSize, child: imageSource == null ? placeholder - : Image.network( - _productImageUrl(imageSource), + : SignedNetworkImage( + imageUrl: imageSource, fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) => - loadingProgress == null ? child : placeholder, - errorBuilder: (context, error, stackTrace) => placeholder, + placeholder: (_, __) => placeholder, + errorWidget: (_, __, ___) => placeholder, ), ), ), @@ -971,14 +970,6 @@ double _fontSize(double designSize, double scale) { return designSize * math.max(scale, 0.9); } -/// 将主屏传来的完整地址或 OSS 相对路径转换为副屏可加载的网络地址。 -String _productImageUrl(String source) { - if (RegExp(r'^https?://', caseSensitive: false).hasMatch(source)) { - return source; - } - final separator = source.startsWith('/') || source.startsWith('?') ? '' : '/'; - return '${Constants.ossUrl}$separator$source'; -} String _countdown(int seconds) { final minutes = seconds ~/ 60; diff --git a/test-vectors/file-access.json b/test-vectors/file-access.json new file mode 100644 index 0000000..1d2cc5b --- /dev/null +++ b/test-vectors/file-access.json @@ -0,0 +1,29 @@ +[ + { + "fileName": "a.jpg", + "appId": "mini", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\nmini\nv1\n1793232000\nYS5qcGc", + "sign": "348b1a5dd5d20560b7537916a6ec525efd173ccc1ee4af5357ca634f26296eb4" + }, + { + "fileName": "照片 +&%.png", + "appId": "admin", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\nadmin\nv1\n1793232000\n54Wn54mHICsmJS5wbmc", + "sign": "9cdaa97a00bf333cf7af13cd915147d474367ef66c9a46adba27f2cbf910ea41" + }, + { + "fileName": "literal%2Fname.apk", + "appId": "package", + "kid": "v1", + "ts": 1793232000, + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-read\npackage\nv1\n1793232000\nbGl0ZXJhbCUyRm5hbWUuYXBr", + "sign": "a28e0b9ec5854749128b0413f0d97c425b8ee785c0c53b41f4469ee509c37e10" + } +] diff --git a/test/file_access_test.dart b/test/file_access_test.dart new file mode 100644 index 0000000..978671e --- /dev/null +++ b/test/file_access_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; + +void main() { + final vectors = + jsonDecode(File('test-vectors/file-access.json').readAsStringSync()) + as List; + for (final v in vectors) { + test('shared Java/TypeScript vector: ${v['fileName']}', () { + expect(fileAccessCanonical(v['fileName'], v['appId'], v['kid'], v['ts']), + v['canonical']); + expect( + fileAccessSignature( + v['fileName'], v['appId'], v['kid'], v['ts'], v['key']), + v['sign']); + }); + } + test( + 'sign only owned files; cache identity survives expiry and old APK URLs keep their suffix', + () { + var now = DateTime.fromMillisecondsSinceEpoch(1793232000000); + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'mini', + kid: 'v1', + key: vectors[0]['key'], + now: () => now); + final signed = client.url('/admin/sys-file/oss/file?fileName=a.jpg'); + expect(Uri.parse(signed).queryParameters['sign'], vectors[0]['sign']); + expect(client.url(signed), signed); + now = now.add(const Duration(minutes: 26)); + final next = client.url(signed); + expect(next, isNot(signed)); + expect(client.original(next), client.original(signed)); + expect( + client.url('installer.apk').endsWith('fileName=installer.apk'), isTrue); + const external = + 'https://other.example/admin/sys-file/oss/file?fileName=a.jpg'; + expect(client.url(external), external); + const url = + 'https://files.example/api/admin/sys-file/oss/file?fileName=a&fileName=b'; + expect(() => client.url(url), throwsFormatException); + }); + test( + 'URL names decode once and clock recovery changes only the transport URL', + () { + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'mini', + kid: 'v1', + key: vectors[0]['key'], + now: () => DateTime.fromMillisecondsSinceEpoch(1793318400000)); + const raw = '/admin/sys-file/oss/file?fileName=literal%252Fname.png'; + final before = client.original(raw); + client.synchronize(1793232000000, 1800, 0); + final signed = Uri.parse(client.url(raw)); + expect(signed.queryParameters['fileName'], 'literal%2Fname.png'); + expect(signed.queryParameters['ts'], '1793232000'); + expect(client.original(signed.toString()), before); + }); + test('local resources, deployment prefixes and stable cache keys', () { + final client = FileAccessClient( + baseUrl: 'https://files.example', + appId: 'flutter', + kid: 'v1', + key: vectors[0]['key']); + expect( + Uri.parse(client.url('/api/admin/sys-file/oss/file?fileName=a.jpg')) + .path, + '/admin/sys-file/oss/file'); + for (final local in [ + '/data/user/0/cache/a.jpg', + 'assets/logo.png', + '/static/a.png', + 'file:///tmp/a.png', + 'data:image/png;base64,AA==' + ]) { + expect(client.url(local), local); + } + const source = '/admin/sys-file/oss/file?fileName=a.jpg&width=20'; + expect(client.original(client.url(source)), client.original(source)); + final prefix = FileAccessClient( + baseUrl: 'https://files.example/gateway/api', + appId: 'flutter', + kid: 'v1', + key: vectors[0]['key']); + expect( + Uri.parse(prefix.url('/admin/sys-file/oss/file?fileName=a.jpg')).path, + '/gateway/api/admin/sys-file/oss/file'); + }); +} diff --git a/test/package_download_test.dart b/test/package_download_test.dart new file mode 100644 index 0000000..b4a5294 --- /dev/null +++ b/test/package_download_test.dart @@ -0,0 +1,52 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/package_download.dart'; + +void main() { + test( + 'expired native APK download requeries once and preserves the target file', + () async { + final task = DownloadTask( + url: 'https://files.example/old.apk', filename: 'target.apk'); + final attempts = []; + var queries = 0; + final result = await downloadPackageWithRefresh( + task: task, + isProtected: (_) => true, + refreshUrl: () async { + queries++; + return 'https://files.example/fresh.apk'; + }, + download: (current) async { + attempts.add(current); + return TaskStatusUpdate( + current, TaskStatus.failed, TaskHttpException('expired', 403)); + }, + ); + expect(result.status, TaskStatus.failed); + expect(queries, 1); + expect(attempts, hasLength(2)); + expect(attempts.last.url, 'https://files.example/fresh.apk'); + expect(attempts.last.filename, task.filename); + expect(attempts.last.taskId, isNot(task.taskId)); + }); + + test('other HTTP errors and external APK servers do not refresh version data', + () async { + for (final item in [(403, false), (404, true), (500, true)]) { + final task = DownloadTask(url: 'https://external.example/a.apk'); + var attempts = 0; + await downloadPackageWithRefresh( + task: task, + isProtected: (_) => item.$2, + refreshUrl: () async => throw StateError('must not query'), + download: (current) async { + attempts++; + return TaskStatusUpdate(current, TaskStatus.failed, + TaskHttpException('failure', item.$1)); + }, + ); + expect(attempts, 1); + } + }); +}