mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-08 15:02:18 +08:00
fix: preserve backup zip responses in lite worker
- send Uint8Array and ArrayBuffer payloads as raw binary in legacy-fastify instead of JSON serializing them - add a Worker response regression test that round-trips a backup zip through fflate - keep lite restore backup aligned with the existing Wallos frontend unzip flow
This commit is contained in:
@@ -17,7 +17,7 @@ import { SettingsSchema } from '@subtracker/shared'
|
||||
import { zipSync } from 'fflate'
|
||||
import { prisma } from '../db'
|
||||
import { getWorkerLogoBucket, getWorkerPublicConfig, isWorkerRuntime } from '../runtime'
|
||||
import { formatDateInTimezone, parseDateInTimezone } from '../utils/timezone'
|
||||
import { formatDateInTimezone, parseDateInTimezone, toTimezonedDayjs } from '../utils/timezone'
|
||||
import { deleteLogoStorageObject, extractLogoStorageKey, getLocalLogoLibrary, saveImportedLogoBufferToKey } from './logo.service'
|
||||
import { getAppSettings, setSetting } from './settings.service'
|
||||
import { getSubscriptionOrder, setSubscriptionOrder } from './subscription-order.service'
|
||||
@@ -99,8 +99,8 @@ function fileTypeFromName(filename: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildBackupFileName(now = new Date()) {
|
||||
const stamp = now.toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z')
|
||||
function buildBackupFileName(timezone: string, now = new Date()) {
|
||||
const stamp = toTimezonedDayjs(now, timezone).format('YYYY-MM-DDTHH-mm-ss')
|
||||
return `subtracker-backup-${stamp}.zip`
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ function buildBackupWarnings(manifest: BackupManifest, canUseR2: boolean) {
|
||||
}
|
||||
|
||||
warnings.push('不会恢复登录凭据、会话密钥、Webhook 历史和汇率快照')
|
||||
warnings.push('追加恢复时,订阅与支付记录按原始 ID 幂等跳过;同名标签会复用现有标签')
|
||||
warnings.push('追加恢复时,订阅与支付记录按备份中的唯一标识(CUID)幂等跳过;同名标签会复用现有标签')
|
||||
|
||||
return warnings
|
||||
}
|
||||
@@ -335,7 +335,7 @@ export async function createSubtrackerBackupArchive() {
|
||||
})
|
||||
|
||||
return {
|
||||
filename: buildBackupFileName(),
|
||||
filename: buildBackupFileName(manifest.data.settings.timezone),
|
||||
contentType: 'application/zip',
|
||||
buffer: Buffer.from(archive)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,30 @@ class LegacyReply {
|
||||
return payload
|
||||
}
|
||||
|
||||
if (payload instanceof Uint8Array) {
|
||||
return new Response(
|
||||
payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer,
|
||||
{
|
||||
status: this.responseStatus,
|
||||
headers: this.headers
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (payload instanceof ArrayBuffer) {
|
||||
return new Response(payload, {
|
||||
status: this.responseStatus,
|
||||
headers: this.headers
|
||||
})
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(payload)) {
|
||||
return new Response(payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer, {
|
||||
status: this.responseStatus,
|
||||
headers: this.headers
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
return new Response(payload, {
|
||||
status: this.responseStatus,
|
||||
|
||||
50
apps/api/tests/unit/legacy-fastify.test.ts
Normal file
50
apps/api/tests/unit/legacy-fastify.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { zipSync, strFromU8, unzipSync } from 'fflate'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LegacyFastifyApp } from '../../src/worker/legacy-fastify'
|
||||
|
||||
describe('LegacyFastifyApp', () => {
|
||||
it('preserves binary payloads instead of JSON stringifying them', async () => {
|
||||
const app = new Hono()
|
||||
const legacy = new LegacyFastifyApp(app, '/worker')
|
||||
const manifest = {
|
||||
app: 'SubTracker'
|
||||
}
|
||||
|
||||
legacy.get('/backup', async (_request, reply) => {
|
||||
const archive = Buffer.from(
|
||||
zipSync({
|
||||
'manifest.json': new TextEncoder().encode(JSON.stringify(manifest))
|
||||
})
|
||||
)
|
||||
reply.header('Content-Type', 'application/zip')
|
||||
return reply.send(archive)
|
||||
})
|
||||
|
||||
const response = await app.request('http://localhost/worker/backup')
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toContain('application/zip')
|
||||
|
||||
const body = new Uint8Array(await response.arrayBuffer())
|
||||
const entries = unzipSync(body)
|
||||
expect(strFromU8(entries['manifest.json'])).toContain('SubTracker')
|
||||
})
|
||||
|
||||
it('still serializes object payloads as json', async () => {
|
||||
const app = new Hono()
|
||||
const legacy = new LegacyFastifyApp(app, '/worker')
|
||||
|
||||
legacy.get('/json', async (_request, reply) => {
|
||||
return reply.send({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
const response = await app.request('http://localhost/worker/json')
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toContain('application/json')
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -225,7 +225,7 @@ describe('subtracker backup service', () => {
|
||||
|
||||
const result = await createSubtrackerBackupArchive()
|
||||
|
||||
expect(result.filename).toContain('subtracker-backup-')
|
||||
expect(result.filename).toBe('subtracker-backup-2026-05-02T16-00-00.zip')
|
||||
expect(result.contentType).toBe('application/zip')
|
||||
const decoded = Buffer.from(result.buffer).toString('binary')
|
||||
expect(decoded.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
<template v-else>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
追加恢复时:同名标签会复用现有标签;订阅与支付记录按原始 ID 幂等跳过;系统设置是否覆盖由你单独选择
|
||||
追加恢复时:同名标签会复用现有标签;订阅与支付记录按备份中的唯一标识(CUID)幂等跳过;系统设置是否覆盖由你单独选择
|
||||
</n-alert>
|
||||
<div class="switch-row">
|
||||
<n-switch v-model:value="restoreSettings" />
|
||||
@@ -78,11 +78,11 @@
|
||||
<strong>{{ preview.conflicts.existingTagNameCount }}</strong>
|
||||
</div>
|
||||
<div class="conflict-row">
|
||||
<span>现有同 ID 订阅:</span>
|
||||
<span>现有同唯一标识(CUID)订阅:</span>
|
||||
<strong>{{ preview.conflicts.existingSubscriptionIdCount }}</strong>
|
||||
</div>
|
||||
<div class="conflict-row">
|
||||
<span>现有同 ID 支付记录:</span>
|
||||
<span>现有同唯一标识(CUID)支付记录:</span>
|
||||
<strong>{{ preview.conflicts.existingPaymentRecordIdCount }}</strong>
|
||||
</div>
|
||||
</n-space>
|
||||
@@ -156,6 +156,23 @@ function normalizePreviewErrorMessage(error: unknown) {
|
||||
return '备份预览失败'
|
||||
}
|
||||
|
||||
function buildRestoreSuccessMessage(result: {
|
||||
importedSubscriptions: number
|
||||
importedTags: number
|
||||
importedPaymentRecords: number
|
||||
importedLogos: number
|
||||
mode: 'replace' | 'append'
|
||||
}) {
|
||||
const importedTotal =
|
||||
result.importedSubscriptions + result.importedTags + result.importedPaymentRecords + result.importedLogos
|
||||
|
||||
if (result.mode === 'append' && importedTotal === 0) {
|
||||
return '未导入任何新数据,重复项已自动跳过'
|
||||
}
|
||||
|
||||
return `恢复完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个新标签,${result.importedPaymentRecords} 条支付记录,${result.importedLogos} 个 Logo`
|
||||
}
|
||||
|
||||
async function inspectFile() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
@@ -186,9 +203,7 @@ async function commitImport() {
|
||||
mode: restoreMode.value,
|
||||
restoreSettings: restoreMode.value === 'replace' ? true : restoreSettings.value
|
||||
})
|
||||
message.success(
|
||||
`恢复完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个新标签,${result.importedPaymentRecords} 条支付记录,${result.importedLogos} 个 Logo`
|
||||
)
|
||||
message.success(buildRestoreSuccessMessage(result))
|
||||
emit('imported', {
|
||||
mode: result.mode,
|
||||
restoredSettings: result.restoredSettings
|
||||
|
||||
@@ -24,6 +24,11 @@ describe('settings import export copy', () => {
|
||||
expect(backupModal).not.toContain('title="导入 ZIP"')
|
||||
expect(backupModal).toContain('预览备份')
|
||||
expect(backupModal).toContain('确认恢复')
|
||||
expect(backupModal).toContain('备份 ZIP 无法解析')
|
||||
expect(backupModal).toContain('未导入任何新数据,重复项已自动跳过')
|
||||
expect(backupModal).toContain('按备份中的唯一标识(CUID)幂等跳过')
|
||||
expect(backupModal).toContain('现有同唯一标识(CUID)订阅')
|
||||
expect(backupModal).toContain('现有同唯一标识(CUID)支付记录')
|
||||
expect(backupModal).not.toContain('确认导入')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user