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:
SmileQWQ
2026-05-02 21:32:26 +08:00
parent e493b72db7
commit 94f0836e7e
6 changed files with 106 additions and 12 deletions

View File

@@ -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)
}

View File

@@ -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,

View 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
})
})
})

View File

@@ -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)

View File

@@ -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

View File

@@ -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('确认导入')
})
})