fix:图片访问
fix:权限修改
This commit is contained in:
1 parent
ef75bb918f
commit
82800373ca
16 files changed
+628
-104
No files matched your search
+4
-1
@@ -51,4 +51,7 @@ app.*.map.json
|
||||
.v2c/
|
||||
.video_agent/
|
||||
.mimosa/
|
||||
.zcode/
|
||||
.zcode/
|
||||
|
||||
# Local file-access credentials
|
||||
.file-access/
|
||||
@@ -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('该版本已下架,请刷新版本列表');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() : '';
|
||||
}
|
||||
}
|
||||
@@ -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 图片地址统一委托给全局解析方法。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user