fix:图片访问

fix:权限修改
This commit is contained in:
dzc committed 2026-10-29 14:26:08 +08:00
1 parent ef75bb918f
commit 82800373ca
16 files changed
+628 -104

No files matched your search

+4 -1
View File
@@ -51,4 +51,7 @@ app.*.map.json
.v2c/
.video_agent/
.mimosa/
.zcode/
.zcode/
# Local file-access credentials
.file-access/
+19
View File
@@ -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<String> 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 ?? <ServiceAppVersion>[]) {
if (item.id == version.id && item.versionCode == version.versionCode &&
item.appUniqueCode == version.appUniqueCode && item.platform == version.platform) {
return item.downloadUrl ?? '';
}
}
throw StateError('该版本已下架,请刷新版本列表');
}
}
+4 -9
View File
@@ -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() : '';
}
}
+18 -51
View File
@@ -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<List<ConnectivityResult>>? 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<imglib.Image?> 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<Uint8List?> 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<void>();
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<void>();
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 图片地址统一委托给全局解析方法。
+288
View File
@@ -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<String> allowedOrigins;
final DateTime Function() now;
int offsetMs = 0, ttlSeconds = 1800;
final _cache = <String, ({int ts, String url})>{};
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<String, List<String>>.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<String, List<String>>.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<int>(0);
static Timer? _timer;
static Future<void>? _pending;
static bool _initialized = false;
static StreamSubscription<List<ConnectivityResult>>? _connectivity;
static DateTime? _lastSync;
static final _retries = <String, DateTime>{};
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<void> 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<void> _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<bool> 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<void>.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();
}
}
}
@@ -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<TaskStatusUpdate> downloadPackageWithRefresh({
required DownloadTask task,
required Future<TaskStatusUpdate> Function(DownloadTask) download,
required Future<String> 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,
));
}
@@ -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);
}
}
+5
View File
@@ -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;
+54 -2
View File
@@ -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<SignedNetworkImage> createState() => _SignedNetworkImageState();
}
class _SignedNetworkImageState extends State<SignedNetworkImage> {
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<int>(
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();
},
),
);
}
@@ -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,
+3
View File
@@ -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<void> checkSubScreen() async {
case "/":
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
FileAccess.initialize();
await AppRuntimeTool.initializePlatform();
final appTheme = Get.put(AppThemeControl());
// 全局餐段状态供商品管理和取餐广播任务读取。
@@ -49,6 +51,7 @@ Future<void> checkSubScreen() async {
case "subMain":
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
FileAccess.initialize();
bool isMultipleScreen = await SubScreenPlugin.isMultipleScreen;
bool isOverlay = await SubScreenPlugin.checkOverlayPermission;
if (isMultipleScreen && isOverlay) {
@@ -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<void> doDownload(String url) async {
Future<void> 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();
+5 -14
View File
@@ -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;
+29
View File
@@ -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"
}
]
+93
View File
@@ -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');
});
}
+52
View File
@@ -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 = <DownloadTask>[];
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);
}
});
}