fix(admin-service+mobile): 修复 OTA 下载三处 Bug

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-03 22:19:10 -08:00
parent 2cf90db0b1
commit 286e4d8886
4 changed files with 47 additions and 17 deletions

View File

@ -56,8 +56,22 @@ export class FileStorageService {
}; };
} }
/** Generate presigned URL for download */ /** Stream file from MinIO to a writable (e.g. Express Response) */
async getDownloadUrl(objectName: string): Promise<string> { async streamFile(objectName: string, destination: NodeJS.WritableStream): Promise<void> {
return this.minio.presignedGetObject(BUCKET, objectName, 24 * 3600); const stream = await this.minio.getObject(BUCKET, objectName);
await new Promise<void>((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',
};
} }
} }

View File

@ -35,13 +35,19 @@ export class AppVersionController {
} }
@Get('download/:id') @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) { async downloadVersion(@Param('id') id: string, @Res() res: Response) {
const version = await this.versionService.getVersion(id); const version = await this.versionService.getVersion(id);
if (version.storageKey) { if (version.storageKey) {
// Generate a fresh 1-hour presigned URL per request — no expiry issue // Stream from MinIO through this service — MinIO is not publicly accessible
const freshUrl = await this.fileStorage.getDownloadUrl(version.storageKey); const stat = await this.fileStorage.statFile(version.storageKey);
return res.redirect(302, freshUrl); 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); return res.redirect(302, version.downloadUrl);
} }

View File

@ -24,8 +24,8 @@ class VersionInfo {
/// ///
final String? updateLog; final String? updateLog;
/// /// null
final DateTime releaseDate; final DateTime? releaseDate;
const VersionInfo({ const VersionInfo({
required this.version, required this.version,
@ -36,7 +36,7 @@ class VersionInfo {
required this.sha256, required this.sha256,
this.forceUpdate = false, this.forceUpdate = false,
this.updateLog, this.updateLog,
required this.releaseDate, this.releaseDate,
}); });
factory VersionInfo.fromJson(Map<String, dynamic> json) { factory VersionInfo.fromJson(Map<String, dynamic> json) {
@ -49,7 +49,9 @@ class VersionInfo {
sha256: json['sha256'] as String, sha256: json['sha256'] as String,
forceUpdate: json['forceUpdate'] as bool? ?? false, forceUpdate: json['forceUpdate'] as bool? ?? false,
updateLog: json['updateLog'] as String?, 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, 'sha256': sha256,
'forceUpdate': forceUpdate, 'forceUpdate': forceUpdate,
'updateLog': updateLog, 'updateLog': updateLog,
'releaseDate': releaseDate.toIso8601String(), 'releaseDate': releaseDate?.toIso8601String(),
}; };
} }

View File

@ -30,6 +30,7 @@ class VersionChecker {
final response = await _dio.get( final response = await _dio.get(
'/api/v1/app/version/check', '/api/v1/app/version/check',
queryParameters: { queryParameters: {
'app_type': 'GENEX_MOBILE',
'platform': 'android', 'platform': 'android',
'current_version': currentInfo.version, 'current_version': currentInfo.version,
'current_version_code': currentInfo.buildNumber, 'current_version_code': currentInfo.buildNumber,
@ -37,16 +38,23 @@ class VersionChecker {
); );
if (response.statusCode == 200 && response.data != null) { if (response.statusCode == 200 && response.data != null) {
final data = response.data is Map<String, dynamic> // API wraps response: {"code":0,"data":{...}} extract the inner object
? response.data as Map<String, dynamic> final responseMap = response.data as Map<String, dynamic>;
: (response.data['data'] as Map<String, dynamic>?) ?? response.data; final data = (responseMap['data'] as Map<String, dynamic>?) ?? responseMap;
final needUpdate = data['needUpdate'] as bool? ?? true; final needUpdate = data['needUpdate'] as bool? ?? false;
if (!needUpdate) { if (!needUpdate) {
debugPrint('[VersionChecker] 无需更新'); debugPrint('[VersionChecker] 无需更新');
return null; 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<String, dynamic>.from(data)..['downloadUrl'] = '$apiBaseUrl$rawUrl')
: data;
return VersionInfo.fromJson(fixedData);
} }
return null; return null;
} catch (e) { } catch (e) {