数据迁移支持电子书和漫画
This commit is contained in:
@@ -44,7 +44,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析请求体获取密码
|
// 解析请求体获取密码
|
||||||
const { password } = await req.json();
|
const { password, includeMangaData = true, includeBookData = true } = await req.json();
|
||||||
if (!password || typeof password !== 'string') {
|
if (!password || typeof password !== 'string') {
|
||||||
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 });
|
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ export async function POST(req: NextRequest) {
|
|||||||
// 所有用户数据
|
// 所有用户数据
|
||||||
userData: {} as { [username: string]: any },
|
userData: {} as { [username: string]: any },
|
||||||
// V2用户信息
|
// V2用户信息
|
||||||
usersV2: [] as any[]
|
usersV2: [] as any[],
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,14 +116,22 @@ export async function POST(req: NextRequest) {
|
|||||||
searchHistory,
|
searchHistory,
|
||||||
skipConfigs,
|
skipConfigs,
|
||||||
musicV2History,
|
musicV2History,
|
||||||
playlists
|
playlists,
|
||||||
|
mangaShelf,
|
||||||
|
mangaReadRecords,
|
||||||
|
bookShelf,
|
||||||
|
bookReadRecords
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db.getAllPlayRecords(username),
|
db.getAllPlayRecords(username),
|
||||||
db.getAllFavorites(username),
|
db.getAllFavorites(username),
|
||||||
db.getSearchHistory(username),
|
db.getSearchHistory(username),
|
||||||
db.getAllSkipConfigs(username),
|
db.getAllSkipConfigs(username),
|
||||||
db.listMusicV2History(username),
|
db.listMusicV2History(username),
|
||||||
db.listMusicV2Playlists(username)
|
db.listMusicV2Playlists(username),
|
||||||
|
includeMangaData ? db.getAllMangaShelf(username) : Promise.resolve({}),
|
||||||
|
includeMangaData ? db.getAllMangaReadRecords(username) : Promise.resolve({}),
|
||||||
|
includeBookData ? db.getAllBookShelf(username) : Promise.resolve({}),
|
||||||
|
includeBookData ? db.getAllBookReadRecords(username) : Promise.resolve({})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 并行获取所有歌单的歌曲
|
// 并行获取所有歌单的歌曲
|
||||||
@@ -143,6 +151,8 @@ export async function POST(req: NextRequest) {
|
|||||||
skipConfigs,
|
skipConfigs,
|
||||||
musicV2History,
|
musicV2History,
|
||||||
musicV2Playlists: playlistsWithSongs,
|
musicV2Playlists: playlistsWithSongs,
|
||||||
|
...(includeMangaData ? { mangaData: { shelf: mangaShelf, readRecords: mangaReadRecords } } : {}),
|
||||||
|
...(includeBookData ? { bookData: { shelf: bookShelf, readRecords: bookReadRecords } } : {}),
|
||||||
passwordV2: finalPasswordV2
|
passwordV2: finalPasswordV2
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 });
|
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importUsernames = Object.keys(importData.data.userData || {});
|
||||||
|
const backupHasMangaData = importUsernames.some((name) => Object.prototype.hasOwnProperty.call(importData.data.userData?.[name] || {}, 'mangaData'));
|
||||||
|
const backupHasBookData = importUsernames.some((name) => Object.prototype.hasOwnProperty.call(importData.data.userData?.[name] || {}, 'bookData'));
|
||||||
|
const preserveMangaData = !backupHasMangaData;
|
||||||
|
const preserveBookData = !backupHasBookData;
|
||||||
|
|
||||||
|
const preservedMangaData = preserveMangaData
|
||||||
|
? Object.fromEntries(await Promise.all(importUsernames.map(async (name) => ([
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
mangaShelf: await db.getAllMangaShelf(name),
|
||||||
|
mangaReadRecords: await db.getAllMangaReadRecords(name),
|
||||||
|
},
|
||||||
|
]))))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const preservedBookData = preserveBookData
|
||||||
|
? Object.fromEntries(await Promise.all(importUsernames.map(async (name) => ([
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
bookShelf: await db.getAllBookShelf(name),
|
||||||
|
bookReadRecords: await db.getAllBookReadRecords(name),
|
||||||
|
},
|
||||||
|
]))))
|
||||||
|
: {};
|
||||||
|
|
||||||
// 开始导入数据 - 先清空现有数据
|
// 开始导入数据 - 先清空现有数据
|
||||||
updateProgress(username, 'import', 'clearing', 0, 1, '正在清空现有数据...');
|
updateProgress(username, 'import', 'clearing', 0, 1, '正在清空现有数据...');
|
||||||
await db.clearAllData();
|
await db.clearAllData();
|
||||||
@@ -371,6 +397,46 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入漫画书架 / 阅读记录
|
||||||
|
(async () => {
|
||||||
|
if (!backupHasMangaData) return;
|
||||||
|
const mangaShelfEntries = Object.entries((user.mangaData?.shelf || preservedMangaData[username]?.mangaShelf || {}));
|
||||||
|
for (let j = 0; j < mangaShelfEntries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = mangaShelfEntries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([, item]: [string, any]) => db.saveMangaShelf(username, item.sourceId, item.mangaId, item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mangaReadEntries = Object.entries((user.mangaData?.readRecords || preservedMangaData[username]?.mangaReadRecords || {}));
|
||||||
|
for (let j = 0; j < mangaReadEntries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = mangaReadEntries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([, record]: [string, any]) => db.saveMangaReadRecord(username, record.sourceId, record.mangaId, record))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// 导入电子书书架 / 阅读记录
|
||||||
|
(async () => {
|
||||||
|
if (!backupHasBookData) return;
|
||||||
|
const bookShelfEntries = Object.entries((user.bookData?.shelf || preservedBookData[username]?.bookShelf || {}));
|
||||||
|
for (let j = 0; j < bookShelfEntries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = bookShelfEntries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([, item]: [string, any]) => db.saveBookShelf(username, item.sourceId, item.bookId, item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookReadEntries = Object.entries((user.bookData?.readRecords || preservedBookData[username]?.bookReadRecords || {}));
|
||||||
|
for (let j = 0; j < bookReadEntries.length; j += DATA_BATCH_SIZE) {
|
||||||
|
const batch = bookReadEntries.slice(j, j + DATA_BATCH_SIZE);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(([, record]: [string, any]) => db.saveBookReadRecord(username, record.sourceId, record.bookId, record))
|
||||||
|
);
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -404,6 +470,8 @@ export async function POST(req: NextRequest) {
|
|||||||
message: '数据导入成功',
|
message: '数据导入成功',
|
||||||
importedUsers: Object.keys(userData).length,
|
importedUsers: Object.keys(userData).length,
|
||||||
importedUsersV2: importData.data.usersV2?.length || 0,
|
importedUsersV2: importData.data.usersV2?.length || 0,
|
||||||
|
importedMangaData: backupHasMangaData,
|
||||||
|
importedBookData: backupHasBookData,
|
||||||
timestamp: importData.timestamp,
|
timestamp: importData.timestamp,
|
||||||
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
const [exportPassword, setExportPassword] = useState('');
|
const [exportPassword, setExportPassword] = useState('');
|
||||||
const [importPassword, setImportPassword] = useState('');
|
const [importPassword, setImportPassword] = useState('');
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [includeMangaExport, setIncludeMangaExport] = useState(true);
|
||||||
|
const [includeBooksExport, setIncludeBooksExport] = useState(true);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [exportProgress, setExportProgress] = useState<{
|
const [exportProgress, setExportProgress] = useState<{
|
||||||
@@ -216,6 +218,8 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
password: exportPassword,
|
password: exportPassword,
|
||||||
|
includeMangaData: includeMangaExport,
|
||||||
|
includeBookData: includeBooksExport,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +340,8 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
<p class="mt-2">导入的用户数量: ${result.importedUsers}</p>
|
<p class="mt-2">导入的用户数量: ${result.importedUsers}</p>
|
||||||
<p>备份时间: ${new Date(result.timestamp).toLocaleString('zh-CN')}</p>
|
<p>备份时间: ${new Date(result.timestamp).toLocaleString('zh-CN')}</p>
|
||||||
<p>服务器版本: ${result.serverVersion || '未知版本'}</p>
|
<p>服务器版本: ${result.serverVersion || '未知版本'}</p>
|
||||||
|
<p>漫画数据: ${result.importedMangaData ? '已导入' : '未导入'}</p>
|
||||||
|
<p>电子书数据: ${result.importedBookData ? '已导入' : '未导入'}</p>
|
||||||
<p class="mt-3 text-orange-600">请刷新页面以查看最新数据。</p>
|
<p class="mt-3 text-orange-600">请刷新页面以查看最新数据。</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -419,6 +425,30 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">附加数据</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeMangaExport}
|
||||||
|
onChange={(e) => setIncludeMangaExport(e.target.checked)}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
漫画数据(书架 + 阅读记录)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeBooksExport}
|
||||||
|
onChange={(e) => setIncludeBooksExport(e.target.checked)}
|
||||||
|
disabled={isExporting}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
电子书数据(书架 + 阅读记录)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 备份内容列表 */}
|
{/* 备份内容列表 */}
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2">备份内容:</p>
|
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2">备份内容:</p>
|
||||||
@@ -427,6 +457,8 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
<div>• 用户数据</div>
|
<div>• 用户数据</div>
|
||||||
<div>• 播放记录</div>
|
<div>• 播放记录</div>
|
||||||
<div>• 收藏夹</div>
|
<div>• 收藏夹</div>
|
||||||
|
<div>• 搜索历史</div>
|
||||||
|
<div>• 音乐数据</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -520,6 +552,7 @@ const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
|||||||
disabled={isImporting}
|
disabled={isImporting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导入按钮 */}
|
{/* 导入按钮 */}
|
||||||
|
|||||||
+10
-4
@@ -2804,9 +2804,6 @@ export class D1Storage implements IStorage {
|
|||||||
'book_shelf',
|
'book_shelf',
|
||||||
'book_read_records',
|
'book_read_records',
|
||||||
'skip_configs',
|
'skip_configs',
|
||||||
'music_play_records',
|
|
||||||
'music_playlists',
|
|
||||||
'music_playlist_songs',
|
|
||||||
'music_v2_history',
|
'music_v2_history',
|
||||||
'music_v2_playlists',
|
'music_v2_playlists',
|
||||||
'music_v2_playlist_items',
|
'music_v2_playlist_items',
|
||||||
@@ -2819,7 +2816,16 @@ export class D1Storage implements IStorage {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
await this.db.prepare(`DELETE FROM ${table}`).run();
|
try {
|
||||||
|
await this.db.prepare(`DELETE FROM ${table}`).run();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('no such table') || message.includes('does not exist')) {
|
||||||
|
console.warn('D1Storage.clearAllData warning:', table, message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('D1Storage.clearAllData error:', err);
|
console.error('D1Storage.clearAllData error:', err);
|
||||||
|
|||||||
+10
-4
@@ -2796,9 +2796,6 @@ export class PostgresStorage implements IStorage {
|
|||||||
'book_shelf',
|
'book_shelf',
|
||||||
'book_read_records',
|
'book_read_records',
|
||||||
'skip_configs',
|
'skip_configs',
|
||||||
'music_play_records',
|
|
||||||
'music_playlists',
|
|
||||||
'music_playlist_songs',
|
|
||||||
'music_v2_history',
|
'music_v2_history',
|
||||||
'music_v2_playlists',
|
'music_v2_playlists',
|
||||||
'music_v2_playlist_items',
|
'music_v2_playlist_items',
|
||||||
@@ -2811,7 +2808,16 @@ export class PostgresStorage implements IStorage {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
await this.db.prepare(`DELETE FROM ${table}`).run();
|
try {
|
||||||
|
await this.db.prepare(`DELETE FROM ${table}`).run();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('no such table') || message.includes('does not exist')) {
|
||||||
|
console.warn('PostgresStorage.clearAllData warning:', table, message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PostgresStorage.clearAllData error:', err);
|
console.error('PostgresStorage.clearAllData error:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user