640 lines
23 KiB
TypeScript
640 lines
23 KiB
TypeScript
/**
|
||
* @file backgroundUploader.ts
|
||
* @description
|
||
* 本模块为基于 Expo 的 React Native 应用提供了一个健壮的后台媒体上传系统。
|
||
* 其核心职责是在用户不感知的情况下,高效地将用户设备上指定时间范围内的照片和视频静默上传到服务器,
|
||
* 即使应用处于后台或被终止也能正常工作。
|
||
*
|
||
* @features
|
||
* - 后台任务处理: 利用 `expo-task-manager` 和 `expo-background-fetch` 注册一个后台任务,
|
||
* 该任务可以周期性(例如每15分钟)运行,即使用户退出应用或设备重启也能被激活。
|
||
* - 媒体文件检索: 使用 `expo-media-library` 访问用户相册,能够获取指定日期范围内的所有媒体文件,
|
||
* 并包含其 EXIF 元数据(如GPS位置、拍摄时间等)。
|
||
* - 文件处理与压缩:
|
||
* - 图片压缩: 通过 `expo-image-manipulator` 调整图片尺寸并进行压缩,以生成用于预览的缩略图。
|
||
* - HEIC 格式转换: 自动将苹果设备上常见的 HEIC/HEIF 格式图片转换为通用的 JPEG 格式再上传。
|
||
* - 视频缩略图: 提取视频的第一帧作为预览图进行上传。
|
||
* - 高效并发上传: 使用 `p-limit` 库限制并发上传的数量(默认为10),以防止网络拥堵并保证上传过程的稳定性。
|
||
* - 手动触发机制: 提供 `triggerManualUpload` 函数,允许应用在需要时(例如用户在设置页面点击“立即同步”)主动触发上传流程。
|
||
*
|
||
* @workflow
|
||
* 1. 触发: 上传流程可以通过两种方式启动:由后台任务调度器自动唤醒,或由应用内代码手动调用。
|
||
* 2. 查询媒体文件: 根据指定的日期范围,从用户设备相册中查询需要上传的图片和视频列表。
|
||
* 3. 处理单个文件 (循环处理列表中的每个文件):
|
||
* a. 权限检查: 确保应用拥有访问媒体库的权限。
|
||
* b. 上传原始文件: 首先上传完整分辨率的原始文件。此过程包括处理 HEIC 转换、从后端获取预签名上传URL、
|
||
* 通过 XMLHttpRequest 上传文件,以及在上传成功后向后端确认。
|
||
* c. 上传派生文件: 接着,生成并上传一个压缩版的图片或视频缩略图,流程与上传原始文件类似。
|
||
* d. 关联素材: 当原始文件和派生文件(压缩图/缩略图)都成功上传后,调用后端API将两者进行关联。
|
||
* 4. 完成: 所有文件处理完毕后,任务结束,等待下一次调度或手动触发。
|
||
*/
|
||
import { fetchApi } from '@/lib/server-api-util';
|
||
import * as BackgroundFetch from 'expo-background-task';
|
||
import * as FileSystem from 'expo-file-system';
|
||
import * as ImageManipulator from 'expo-image-manipulator';
|
||
import * as MediaLibrary from 'expo-media-library';
|
||
import * as TaskManager from 'expo-task-manager';
|
||
import pLimit from 'p-limit';
|
||
import { Alert } from 'react-native';
|
||
import { transformData } from '../utils/objectFlat';
|
||
|
||
|
||
type ExtendedAsset = MediaLibrary.Asset & {
|
||
exif?: Record<string, any>;
|
||
};
|
||
|
||
const BACKGROUND_UPLOAD_TASK = 'background-upload-task';
|
||
// 设置最大并发数
|
||
const CONCURRENCY_LIMIT = 10; // 同时最多上传10个文件
|
||
// 在 CONCURRENCY_LIMIT 定义后添加
|
||
const limit = pLimit(CONCURRENCY_LIMIT);
|
||
|
||
// 获取文件扩展名
|
||
const getFileExtension = (filename: string) => {
|
||
return filename.split('.').pop()?.toLowerCase() || '';
|
||
};
|
||
|
||
// 获取 MIME 类型
|
||
const getMimeType = (filename: string, isVideo: boolean) => {
|
||
if (!isVideo) return 'image/jpeg';
|
||
|
||
const ext = getFileExtension(filename);
|
||
switch (ext) {
|
||
case 'mov':
|
||
return 'video/quicktime';
|
||
case 'mp4':
|
||
return 'video/mp4';
|
||
case 'm4v':
|
||
return 'video/x-m4v';
|
||
default:
|
||
return 'video/mp4'; // 默认值
|
||
}
|
||
};
|
||
|
||
// 将 HEIC 图片转化
|
||
const convertHeicToJpeg = async (uri: string): Promise<File> => {
|
||
try {
|
||
console.log('Starting HEIC to JPEG conversion for:', uri);
|
||
|
||
// 1. 将文件复制到缓存目录
|
||
const cacheDir = FileSystem.cacheDirectory;
|
||
if (!cacheDir) {
|
||
throw new Error('Cache directory not available');
|
||
}
|
||
|
||
// 创建唯一的文件名
|
||
const tempUri = `${cacheDir}${Date.now()}.heic`;
|
||
|
||
// 复制文件到缓存目录
|
||
await FileSystem.copyAsync({
|
||
from: uri,
|
||
to: tempUri
|
||
});
|
||
|
||
// 2. 检查文件是否存在
|
||
const fileInfo = await FileSystem.getInfoAsync(tempUri);
|
||
if (!fileInfo.exists) {
|
||
throw new Error('Temporary file was not created');
|
||
}
|
||
|
||
// 3. 读取文件为 base64
|
||
const base64 = await FileSystem.readAsStringAsync(tempUri, {
|
||
encoding: FileSystem.EncodingType.Base64,
|
||
});
|
||
|
||
if (!base64) {
|
||
throw new Error('Failed to read file as base64');
|
||
}
|
||
|
||
// 4. 创建 Blob
|
||
const response = await fetch(`data:image/jpeg;base64,${base64}`);
|
||
if (!response.ok) {
|
||
throw new Error(`Fetch failed with status ${response.status}`);
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
if (!blob || blob.size === 0) {
|
||
throw new Error('Failed to create blob from base64');
|
||
}
|
||
|
||
// 5. 创建文件名
|
||
const originalName = uri.split('/').pop() || 'converted';
|
||
const filename = originalName.replace(/\.(heic|heif)$/i, '.jpg');
|
||
|
||
console.log('Successfully converted HEIC to JPEG:', filename);
|
||
|
||
// 清理临时文件
|
||
try {
|
||
await FileSystem.deleteAsync(tempUri, { idempotent: true });
|
||
} catch (cleanupError) {
|
||
console.warn('Failed to clean up temporary file:', cleanupError);
|
||
}
|
||
|
||
return new File([blob], filename, { type: 'image/jpeg' });
|
||
} catch (error: unknown) {
|
||
console.error('Detailed HEIC conversion error:', {
|
||
error: error instanceof Error ? {
|
||
message: error.message,
|
||
name: error.name,
|
||
stack: error.stack
|
||
} : error,
|
||
uri: uri
|
||
});
|
||
throw new Error(`Failed to convert HEIC image: ${error instanceof Error ? error.message : 'An unknown error occurred'}`);
|
||
}
|
||
};
|
||
|
||
// 获取指定时间范围内的媒体文件(包含 EXIF 信息)
|
||
export const getMediaByDateRange = async (startDate: Date, endDate: Date) => {
|
||
try {
|
||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||
if (status !== 'granted') {
|
||
console.warn('Media library permission not granted');
|
||
return [];
|
||
}
|
||
|
||
const media = await MediaLibrary.getAssetsAsync({
|
||
mediaType: ['photo', 'video'],
|
||
first: 100,
|
||
sortBy: [MediaLibrary.SortBy.creationTime],
|
||
createdAfter: startDate.getTime(),
|
||
createdBefore: endDate.getTime(),
|
||
});
|
||
|
||
// 为每个资源获取完整的 EXIF 信息
|
||
const assetsWithExif = await Promise.all(
|
||
media.assets.map(async (asset) => {
|
||
try {
|
||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id);
|
||
return {
|
||
...asset,
|
||
exif: assetInfo.exif || null,
|
||
location: assetInfo.location || null
|
||
};
|
||
} catch (error) {
|
||
console.warn(`Failed to get EXIF for asset ${asset.id}:`, error);
|
||
return asset;
|
||
}
|
||
})
|
||
);
|
||
|
||
return assetsWithExif;
|
||
} catch (error) {
|
||
console.error('Error in getMediaByDateRange:', error);
|
||
return [];
|
||
}
|
||
};
|
||
|
||
// 压缩图片
|
||
const compressImage = async (uri: string): Promise<{ uri: string; file: File }> => {
|
||
try {
|
||
const manipResult = await ImageManipulator.manipulateAsync(
|
||
uri,
|
||
[
|
||
{
|
||
resize: {
|
||
width: 1200,
|
||
height: 1200,
|
||
},
|
||
},
|
||
],
|
||
{
|
||
compress: 0.7,
|
||
format: ImageManipulator.SaveFormat.JPEG,
|
||
base64: false,
|
||
}
|
||
);
|
||
|
||
const response = await fetch(manipResult.uri);
|
||
const blob = await response.blob();
|
||
const filename = uri.split('/').pop() || `image_${Date.now()}.jpg`;
|
||
const file = new File([blob], filename, { type: 'image/jpeg' });
|
||
|
||
return { uri: manipResult.uri, file };
|
||
} catch (error) {
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 获取上传URL
|
||
const getUploadUrl = async (file: File, metadata: Record<string, any> = {}): Promise<{ upload_url: string; file_id: string }> => {
|
||
|
||
const body = {
|
||
filename: file.name,
|
||
content_type: file.type,
|
||
file_size: file.size,
|
||
metadata: {
|
||
...metadata,
|
||
originalName: file.name,
|
||
fileType: file.type.startsWith('video/') ? 'video' : 'image',
|
||
isCompressed: 'true',
|
||
},
|
||
};
|
||
|
||
return await fetchApi<{ upload_url: string; file_id: string }>("/file/generate-upload-url", {
|
||
method: 'POST',
|
||
body: JSON.stringify(body)
|
||
});
|
||
};
|
||
|
||
// 确认上传
|
||
const confirmUpload = async (file_id: string) => {
|
||
return await fetchApi('/file/confirm-upload', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ file_id })
|
||
});
|
||
};
|
||
|
||
// 新增素材
|
||
const addMaterial = async (file: string, compressFile: string) => {
|
||
await fetchApi('/material', {
|
||
method: 'POST',
|
||
body: JSON.stringify([{
|
||
"file_id": file,
|
||
"preview_file_id": compressFile
|
||
}])
|
||
}).catch((error) => {
|
||
})
|
||
}
|
||
|
||
// 上传文件到URL
|
||
const uploadFile = async (file: File, uploadUrl: string): Promise<void> => {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
xhr.open('PUT', uploadUrl);
|
||
xhr.setRequestHeader('Content-Type', file.type);
|
||
|
||
// 进度监听
|
||
xhr.upload.onprogress = (event) => {
|
||
if (event.lengthComputable) {
|
||
const progress = Math.round((event.loaded / event.total) * 100);
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => {
|
||
reject(new Error('Network error during upload'));
|
||
};
|
||
|
||
xhr.send(file);
|
||
});
|
||
};
|
||
|
||
// 检查并请求媒体库权限
|
||
const checkMediaLibraryPermission = async (): Promise<{ hasPermission: boolean, status?: string }> => {
|
||
try {
|
||
const { status, accessPrivileges } = await MediaLibrary.getPermissionsAsync();
|
||
|
||
// 如果已经授权,直接返回
|
||
if (status === 'granted' && accessPrivileges === 'all') {
|
||
return { hasPermission: true, status };
|
||
}
|
||
|
||
// 如果没有授权,请求权限
|
||
const { status: newStatus, accessPrivileges: newPrivileges } = await MediaLibrary.requestPermissionsAsync();
|
||
const isGranted = newStatus === 'granted' && newPrivileges === 'all';
|
||
|
||
if (!isGranted) {
|
||
console.log('Media library permission not granted or limited access');
|
||
}
|
||
|
||
return {
|
||
hasPermission: isGranted,
|
||
status: newStatus
|
||
};
|
||
} catch (error) {
|
||
return { hasPermission: false };
|
||
}
|
||
};
|
||
// 提取视频的首帧进行压缩并上传
|
||
const uploadVideoThumbnail = async (asset: ExtendedAsset) => {
|
||
try {
|
||
const manipResult = await compressImage(asset.uri);
|
||
const response = await fetch(manipResult.uri);
|
||
const blob = await response.blob();
|
||
const filename = asset.filename ?
|
||
`compressed_${asset.filename}` :
|
||
`image_${Date.now()}_compressed.jpg`;
|
||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||
|
||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||
originalUri: asset.uri,
|
||
creationTime: asset.creationTime,
|
||
mediaType: 'image',
|
||
isCompressed: true
|
||
});
|
||
|
||
await uploadFile(compressedFile, upload_url);
|
||
await confirmUpload(file_id);
|
||
|
||
console.log('视频首帧文件上传成功:', {
|
||
fileId: file_id,
|
||
filename: compressedFile.name,
|
||
type: compressedFile.type
|
||
});
|
||
return { success: true, file_id };
|
||
} catch (error) {
|
||
return { success: false, error };
|
||
}
|
||
};
|
||
// 处理单个媒体文件上传
|
||
const processMediaUpload = async (asset: ExtendedAsset) => {
|
||
try {
|
||
// 检查权限
|
||
const { hasPermission } = await checkMediaLibraryPermission();
|
||
if (!hasPermission) {
|
||
throw new Error('No media library permission');
|
||
}
|
||
|
||
const isVideo = asset.mediaType === 'video';
|
||
|
||
// 上传原始文件
|
||
const uploadOriginalFile = async () => {
|
||
try {
|
||
let fileToUpload: File;
|
||
const isVideo = asset.mediaType === 'video';
|
||
const mimeType = getMimeType(asset.filename || '', isVideo);
|
||
|
||
// 生成文件名,保留原始扩展名
|
||
let filename = asset.filename ||
|
||
`${isVideo ? 'video' : 'image'}_${Date.now()}_original.${isVideo ? (getFileExtension(asset.filename || 'mp4') || 'mp4') : 'jpg'}`;
|
||
|
||
// 处理 HEIC 格式
|
||
if (filename.toLowerCase().endsWith('.heic') || filename.toLowerCase().endsWith('.heif')) {
|
||
fileToUpload = await convertHeicToJpeg(asset.uri);
|
||
filename = filename.replace(/\.(heic|heif)$/i, '.jpg');
|
||
} else {
|
||
// 获取资源信息
|
||
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.id, {
|
||
shouldDownloadFromNetwork: true
|
||
});
|
||
|
||
if (!assetInfo.localUri) {
|
||
throw new Error('无法获取资源的本地路径');
|
||
}
|
||
|
||
// 获取文件扩展名
|
||
const fileExtension = getFileExtension(assetInfo.filename || '') ||
|
||
(isVideo ? 'mp4' : 'jpg');
|
||
|
||
// 确保文件名有正确的扩展名
|
||
if (!filename.toLowerCase().endsWith(`.${fileExtension}`)) {
|
||
const baseName = filename.split('.')[0];
|
||
filename = `${baseName}.${fileExtension}`;
|
||
}
|
||
|
||
// 获取文件内容
|
||
const response = await fetch(assetInfo.localUri);
|
||
const blob = await response.blob();
|
||
|
||
// 创建文件对象
|
||
fileToUpload = new File([blob], filename, { type: mimeType });
|
||
console.log('文件准备上传:', {
|
||
name: fileToUpload.name,
|
||
type: fileToUpload.type,
|
||
size: fileToUpload.size
|
||
});
|
||
}
|
||
|
||
// 准备元数据
|
||
let exifData = {};
|
||
if (asset.exif) {
|
||
try {
|
||
exifData = transformData({
|
||
...asset,
|
||
exif: {
|
||
...asset.exif,
|
||
'{MakerApple}': undefined
|
||
}
|
||
});
|
||
} catch (exifError) {
|
||
console.warn('处理 EXIF 数据时出错:', exifError);
|
||
}
|
||
}
|
||
|
||
// 获取上传 URL
|
||
const { upload_url, file_id } = await getUploadUrl(fileToUpload, {
|
||
originalUri: asset.uri,
|
||
creationTime: asset.creationTime,
|
||
mediaType: isVideo ? 'video' : 'image',
|
||
isCompressed: false,
|
||
...exifData,
|
||
GPSVersionID: undefined
|
||
});
|
||
|
||
// 上传文件
|
||
await uploadFile(fileToUpload, upload_url);
|
||
await confirmUpload(file_id);
|
||
|
||
console.log('文件上传成功:', {
|
||
fileId: file_id,
|
||
filename: fileToUpload.name,
|
||
type: fileToUpload.type
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
file_id,
|
||
filename: fileToUpload.name
|
||
};
|
||
|
||
} catch (error: any) {
|
||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||
console.error('上传原始文件时出错:', {
|
||
error: errorMessage,
|
||
assetId: asset.id,
|
||
filename: asset.filename,
|
||
uri: asset.uri
|
||
});
|
||
throw new Error(`上传失败: ${errorMessage}`);
|
||
}
|
||
};
|
||
|
||
// 上传压缩文件(仅图片)
|
||
const uploadCompressedFile = async () => {
|
||
if (isVideo) return { success: true, file_id: null }; // 视频不压缩
|
||
|
||
try {
|
||
const manipResult = await compressImage(asset.uri);
|
||
const response = await fetch(manipResult.uri);
|
||
const blob = await response.blob();
|
||
const filename = asset.filename ?
|
||
`compressed_${asset.filename}` :
|
||
`image_${Date.now()}_compressed.jpg`;
|
||
const compressedFile = new File([blob], filename, { type: 'image/jpeg' });
|
||
|
||
const { upload_url, file_id } = await getUploadUrl(compressedFile, {
|
||
originalUri: asset.uri,
|
||
creationTime: asset.creationTime,
|
||
mediaType: 'image',
|
||
isCompressed: true
|
||
});
|
||
|
||
await uploadFile(compressedFile, upload_url);
|
||
await confirmUpload(file_id);
|
||
return { success: true, file_id };
|
||
} catch (error) {
|
||
return { success: false, error };
|
||
}
|
||
};
|
||
|
||
// 先上传原始文件
|
||
const originalResult = await uploadOriginalFile();
|
||
|
||
// 如果是图片,再上传压缩文件
|
||
let compressedResult = { success: true, file_id: null };
|
||
if (!isVideo) {
|
||
compressedResult = await uploadCompressedFile();
|
||
// 添加素材
|
||
addMaterial(originalResult.file_id, compressedResult?.file_id || '');
|
||
} else {
|
||
// 上传压缩首帧
|
||
uploadVideoThumbnail(asset)
|
||
}
|
||
|
||
return {
|
||
originalSuccess: originalResult.success,
|
||
compressedSuccess: compressedResult.success,
|
||
fileIds: {
|
||
original: originalResult.file_id,
|
||
compressed: compressedResult.file_id
|
||
}
|
||
};
|
||
} catch (error: any) {
|
||
if (error.message === 'No media library permission') {
|
||
throw error;
|
||
}
|
||
return {
|
||
originalSuccess: false,
|
||
compressedSuccess: false,
|
||
error: error.message
|
||
};
|
||
}
|
||
};
|
||
|
||
// 注册后台任务
|
||
export const registerBackgroundUploadTask = async () => {
|
||
try {
|
||
// 检查是否已经注册了任务
|
||
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_UPLOAD_TASK);
|
||
if (isRegistered) {
|
||
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_UPLOAD_TASK);
|
||
}
|
||
|
||
// 注册后台任务
|
||
await BackgroundFetch.registerTaskAsync(BACKGROUND_UPLOAD_TASK, {
|
||
minimumInterval: 15 * 60, // 15 分钟
|
||
stopOnTerminate: false, // 应用退出后继续运行
|
||
startOnBoot: true, // 设备启动后自动启动
|
||
});
|
||
|
||
console.log('Background task registered');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Error registering background task:', error);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
// 定义后台任务
|
||
TaskManager.defineTask(BACKGROUND_UPLOAD_TASK, async () => {
|
||
try {
|
||
const now = new Date();
|
||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||
|
||
// 获取最近24小时的媒体文件
|
||
const media = await getMediaByDateRange(oneDayAgo, now);
|
||
|
||
if (media.length === 0) {
|
||
console.log('No media files to upload');
|
||
return BackgroundFetch.BackgroundTaskResult.Success;
|
||
}
|
||
|
||
// 处理媒体文件上传
|
||
const results = await triggerManualUpload(oneDayAgo, now);
|
||
const successCount = results.filter(r => r.originalSuccess).length;
|
||
|
||
console.log(`Background upload completed. Success: ${successCount}/${results.length}`);
|
||
|
||
return successCount > 0
|
||
? BackgroundFetch.BackgroundTaskResult.Success
|
||
: BackgroundFetch.BackgroundTaskResult.Failed;
|
||
} catch (error) {
|
||
console.error('Background task error:', error);
|
||
return BackgroundFetch.BackgroundTaskResult.Failed;
|
||
}
|
||
});
|
||
|
||
// 手动触发上传
|
||
export const triggerManualUpload = async (startDate: Date, endDate: Date) => {
|
||
try {
|
||
const media = await getMediaByDateRange(startDate, endDate);
|
||
if (media.length === 0) {
|
||
Alert.alert('提示', '在指定时间范围内未找到媒体文件');
|
||
return [];
|
||
}
|
||
|
||
// 分离图片和视频
|
||
const photos = media.filter(item => item.mediaType === 'photo');
|
||
const videos = media.filter(item => item.mediaType === 'video');
|
||
console.log('videos11111111', videos);
|
||
|
||
const results: any[] = [];
|
||
|
||
// 处理所有图片(带并发控制)
|
||
const processPhoto = async (item: any) => {
|
||
try {
|
||
const result = await processMediaUpload(item);
|
||
results.push({
|
||
id: item.id,
|
||
...result
|
||
});
|
||
} catch (error: any) {
|
||
results.push({
|
||
id: item.id,
|
||
originalSuccess: false,
|
||
compressedSuccess: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
// 处理所有视频(带并发控制)
|
||
const processVideo = async (item: any) => {
|
||
try {
|
||
const result = await processMediaUpload(item);
|
||
results.push({
|
||
id: item.id,
|
||
...result
|
||
});
|
||
} catch (error: any) {
|
||
results.push({
|
||
id: item.id,
|
||
originalSuccess: false,
|
||
compressedSuccess: false,
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
|
||
// 并发处理图片和视频
|
||
await Promise.all([
|
||
...photos.map(photo => limit(() => processPhoto(photo))),
|
||
...videos.map(video => limit(() => processVideo(video)))
|
||
]);
|
||
|
||
return results;
|
||
} catch (error) {
|
||
Alert.alert('错误', '上传过程中出现错误');
|
||
throw error;
|
||
}
|
||
};
|