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 */
async getDownloadUrl(objectName: string): Promise<string> {
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<void> {
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')
@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);
}

View File

@ -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<String, dynamic> 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(),
};
}

View File

@ -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<String, dynamic>
? response.data as Map<String, dynamic>
: (response.data['data'] as Map<String, dynamic>?) ?? response.data;
// API wraps response: {"code":0,"data":{...}} extract the inner object
final responseMap = response.data as Map<String, dynamic>;
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) {
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<String, dynamic>.from(data)..['downloadUrl'] = '$apiBaseUrl$rawUrl')
: data;
return VersionInfo.fromJson(fixedData);
}
return null;
} catch (e) {