From 94f0836e7e0dbbb7c1fe7f7fefa8e67f0cebd0d6 Mon Sep 17 00:00:00 2001 From: SmileQWQ Date: Sat, 2 May 2026 21:32:26 +0800 Subject: [PATCH] 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 --- .../src/services/subtracker-backup.service.ts | 10 ++-- apps/api/src/worker/legacy-fastify.ts | 24 +++++++++ apps/api/tests/unit/legacy-fastify.test.ts | 50 +++++++++++++++++++ .../unit/subtracker-backup.service.test.ts | 2 +- .../src/components/SubtrackerBackupModal.vue | 27 +++++++--- .../components/settings-import-export.test.ts | 5 ++ 6 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 apps/api/tests/unit/legacy-fastify.test.ts diff --git a/apps/api/src/services/subtracker-backup.service.ts b/apps/api/src/services/subtracker-backup.service.ts index 57847e2..8d20332 100644 --- a/apps/api/src/services/subtracker-backup.service.ts +++ b/apps/api/src/services/subtracker-backup.service.ts @@ -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) } diff --git a/apps/api/src/worker/legacy-fastify.ts b/apps/api/src/worker/legacy-fastify.ts index 7f9dd6e..fa1b1e2 100644 --- a/apps/api/src/worker/legacy-fastify.ts +++ b/apps/api/src/worker/legacy-fastify.ts @@ -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, diff --git a/apps/api/tests/unit/legacy-fastify.test.ts b/apps/api/tests/unit/legacy-fastify.test.ts new file mode 100644 index 0000000..c3c2554 --- /dev/null +++ b/apps/api/tests/unit/legacy-fastify.test.ts @@ -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 + }) + }) +}) diff --git a/apps/api/tests/unit/subtracker-backup.service.test.ts b/apps/api/tests/unit/subtracker-backup.service.test.ts index 0a23068..8256448 100644 --- a/apps/api/tests/unit/subtracker-backup.service.test.ts +++ b/apps/api/tests/unit/subtracker-backup.service.test.ts @@ -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) diff --git a/apps/web/src/components/SubtrackerBackupModal.vue b/apps/web/src/components/SubtrackerBackupModal.vue index 7bf9036..77fdd44 100644 --- a/apps/web/src/components/SubtrackerBackupModal.vue +++ b/apps/web/src/components/SubtrackerBackupModal.vue @@ -61,7 +61,7 @@