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:
parent
2cf90db0b1
commit
286e4d8886
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue