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 */
|
/** 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',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue