From 286e4d88867fa92a28600ad59625453cf0d50ec6 Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 3 Mar 2026 22:19:10 -0800 Subject: [PATCH] =?UTF-8?q?fix(admin-service+mobile):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20OTA=20=E4=B8=8B=E8=BD=BD=E4=B8=89=E5=A4=84=20Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. MinIO 内网 hostname 导致 redirect 失效 → 改为 admin-service 直接流式代理传输文件(streamFile) → MinIO 仅绑 127.0.0.1:49000,不对外暴露 2. version_checker.dart 响应解析错误 → API 返回 {"code":0,"data":{...}} 但代码误把外层 Map 当 VersionInfo → 现在正确提取 responseMap["data"] 内层对象 3. VersionInfo.fromJson 在 releaseDate=null 时崩溃 → releaseDate 改为 nullable (DateTime?),null-safe 解析 → 同步修复 version_checker.dart 拼接绝对 downloadUrl Co-Authored-By: Claude Sonnet 4.6 --- .../services/file-storage.service.ts | 20 ++++++++++++++++--- .../controllers/app-version.controller.ts | 14 +++++++++---- .../lib/core/updater/models/version_info.dart | 12 ++++++----- .../lib/core/updater/version_checker.dart | 18 ++++++++++++----- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/backend/services/admin-service/src/application/services/file-storage.service.ts b/backend/services/admin-service/src/application/services/file-storage.service.ts index e5a3bce..170fc00 100644 --- a/backend/services/admin-service/src/application/services/file-storage.service.ts +++ b/backend/services/admin-service/src/application/services/file-storage.service.ts @@ -56,8 +56,22 @@ export class FileStorageService { }; } - /** Generate presigned URL for download */ - async getDownloadUrl(objectName: string): Promise { - return this.minio.presignedGetObject(BUCKET, objectName, 24 * 3600); + /** Stream file from MinIO to a writable (e.g. Express Response) */ + async streamFile(objectName: string, destination: NodeJS.WritableStream): Promise { + const stream = await this.minio.getObject(BUCKET, objectName); + await new Promise((resolve, reject) => { + stream.pipe(destination as any); + stream.on('end', resolve); + stream.on('error', reject); + }); + } + + /** Get file metadata (size) from MinIO */ + async statFile(objectName: string): Promise<{ size: number; contentType: string }> { + const stat = await this.minio.statObject(BUCKET, objectName); + return { + size: stat.size, + contentType: (stat.metaData as any)?.['content-type'] || 'application/octet-stream', + }; } } diff --git a/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts index 8edcc9d..0b4615f 100644 --- a/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts @@ -35,13 +35,19 @@ export class AppVersionController { } @Get('download/:id') - @ApiOperation({ summary: 'Download app package (regenerates fresh presigned URL each request)' }) + @ApiOperation({ summary: 'Download app package — streams through service (MinIO not directly exposed)' }) async downloadVersion(@Param('id') id: string, @Res() res: Response) { const version = await this.versionService.getVersion(id); if (version.storageKey) { - // Generate a fresh 1-hour presigned URL per request — no expiry issue - const freshUrl = await this.fileStorage.getDownloadUrl(version.storageKey); - return res.redirect(302, freshUrl); + // Stream from MinIO through this service — MinIO is not publicly accessible + const stat = await this.fileStorage.statFile(version.storageKey); + const ext = version.storageKey.split('.').pop() || 'apk'; + const filename = `app-${version.versionName}.${ext}`; + res.setHeader('Content-Type', stat.contentType); + res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + await this.fileStorage.streamFile(version.storageKey, res); + return; } return res.redirect(302, version.downloadUrl); } diff --git a/frontend/genex-mobile/lib/core/updater/models/version_info.dart b/frontend/genex-mobile/lib/core/updater/models/version_info.dart index 10f7c93..64306f8 100644 --- a/frontend/genex-mobile/lib/core/updater/models/version_info.dart +++ b/frontend/genex-mobile/lib/core/updater/models/version_info.dart @@ -24,8 +24,8 @@ class VersionInfo { /// 更新日志 final String? updateLog; - /// 发布时间 - final DateTime releaseDate; + /// 发布时间(可为空,后台未设置时为 null) + final DateTime? releaseDate; const VersionInfo({ required this.version, @@ -36,7 +36,7 @@ class VersionInfo { required this.sha256, this.forceUpdate = false, this.updateLog, - required this.releaseDate, + this.releaseDate, }); factory VersionInfo.fromJson(Map json) { @@ -49,7 +49,9 @@ class VersionInfo { sha256: json['sha256'] as String, forceUpdate: json['forceUpdate'] as bool? ?? false, updateLog: json['updateLog'] as String?, - releaseDate: DateTime.parse(json['releaseDate'] as String), + releaseDate: json['releaseDate'] != null + ? DateTime.parse(json['releaseDate'] as String) + : null, ); } @@ -63,7 +65,7 @@ class VersionInfo { 'sha256': sha256, 'forceUpdate': forceUpdate, 'updateLog': updateLog, - 'releaseDate': releaseDate.toIso8601String(), + 'releaseDate': releaseDate?.toIso8601String(), }; } diff --git a/frontend/genex-mobile/lib/core/updater/version_checker.dart b/frontend/genex-mobile/lib/core/updater/version_checker.dart index 6f2ebfa..d6998c0 100644 --- a/frontend/genex-mobile/lib/core/updater/version_checker.dart +++ b/frontend/genex-mobile/lib/core/updater/version_checker.dart @@ -30,6 +30,7 @@ class VersionChecker { final response = await _dio.get( '/api/v1/app/version/check', queryParameters: { + 'app_type': 'GENEX_MOBILE', 'platform': 'android', 'current_version': currentInfo.version, 'current_version_code': currentInfo.buildNumber, @@ -37,16 +38,23 @@ class VersionChecker { ); if (response.statusCode == 200 && response.data != null) { - final data = response.data is Map - ? response.data as Map - : (response.data['data'] as Map?) ?? response.data; + // API wraps response: {"code":0,"data":{...}} — extract the inner object + final responseMap = response.data as Map; + final data = (responseMap['data'] as Map?) ?? responseMap; - final needUpdate = data['needUpdate'] as bool? ?? true; + final needUpdate = data['needUpdate'] as bool? ?? false; if (!needUpdate) { debugPrint('[VersionChecker] 无需更新'); return null; } - return VersionInfo.fromJson(data); + + // Fix relative downloadUrl → prepend apiBaseUrl for DownloadManager + final rawUrl = data['downloadUrl'] as String? ?? ''; + final fixedData = (rawUrl.isNotEmpty && !rawUrl.startsWith('http')) + ? (Map.from(data)..['downloadUrl'] = '$apiBaseUrl$rawUrl') + : data; + + return VersionInfo.fromJson(fixedData); } return null; } catch (e) {