Skip to content

Commit 94f0836

Browse files
committed
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
1 parent e493b72 commit 94f0836

6 files changed

Lines changed: 106 additions & 12 deletions

File tree

apps/api/src/services/subtracker-backup.service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { SettingsSchema } from '@subtracker/shared'
1717
import { zipSync } from 'fflate'
1818
import { prisma } from '../db'
1919
import { getWorkerLogoBucket, getWorkerPublicConfig, isWorkerRuntime } from '../runtime'
20-
import { formatDateInTimezone, parseDateInTimezone } from '../utils/timezone'
20+
import { formatDateInTimezone, parseDateInTimezone, toTimezonedDayjs } from '../utils/timezone'
2121
import { deleteLogoStorageObject, extractLogoStorageKey, getLocalLogoLibrary, saveImportedLogoBufferToKey } from './logo.service'
2222
import { getAppSettings, setSetting } from './settings.service'
2323
import { getSubscriptionOrder, setSubscriptionOrder } from './subscription-order.service'
@@ -99,8 +99,8 @@ function fileTypeFromName(filename: string) {
9999
}
100100
}
101101

102-
function buildBackupFileName(now = new Date()) {
103-
const stamp = now.toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z')
102+
function buildBackupFileName(timezone: string, now = new Date()) {
103+
const stamp = toTimezonedDayjs(now, timezone).format('YYYY-MM-DDTHH-mm-ss')
104104
return `subtracker-backup-${stamp}.zip`
105105
}
106106

@@ -166,7 +166,7 @@ function buildBackupWarnings(manifest: BackupManifest, canUseR2: boolean) {
166166
}
167167

168168
warnings.push('不会恢复登录凭据、会话密钥、Webhook 历史和汇率快照')
169-
warnings.push('追加恢复时,订阅与支付记录按原始 ID 幂等跳过;同名标签会复用现有标签')
169+
warnings.push('追加恢复时,订阅与支付记录按备份中的唯一标识(CUID)幂等跳过;同名标签会复用现有标签')
170170

171171
return warnings
172172
}
@@ -335,7 +335,7 @@ export async function createSubtrackerBackupArchive() {
335335
})
336336

337337
return {
338-
filename: buildBackupFileName(),
338+
filename: buildBackupFileName(manifest.data.settings.timezone),
339339
contentType: 'application/zip',
340340
buffer: Buffer.from(archive)
341341
}

apps/api/src/worker/legacy-fastify.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,30 @@ class LegacyReply {
3737
return payload
3838
}
3939

40+
if (payload instanceof Uint8Array) {
41+
return new Response(
42+
payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer,
43+
{
44+
status: this.responseStatus,
45+
headers: this.headers
46+
}
47+
)
48+
}
49+
50+
if (payload instanceof ArrayBuffer) {
51+
return new Response(payload, {
52+
status: this.responseStatus,
53+
headers: this.headers
54+
})
55+
}
56+
57+
if (ArrayBuffer.isView(payload)) {
58+
return new Response(payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer, {
59+
status: this.responseStatus,
60+
headers: this.headers
61+
})
62+
}
63+
4064
if (typeof payload === 'string') {
4165
return new Response(payload, {
4266
status: this.responseStatus,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { zipSync, strFromU8, unzipSync } from 'fflate'
2+
import { Hono } from 'hono'
3+
import { describe, expect, it } from 'vitest'
4+
import { LegacyFastifyApp } from '../../src/worker/legacy-fastify'
5+
6+
describe('LegacyFastifyApp', () => {
7+
it('preserves binary payloads instead of JSON stringifying them', async () => {
8+
const app = new Hono()
9+
const legacy = new LegacyFastifyApp(app, '/worker')
10+
const manifest = {
11+
app: 'SubTracker'
12+
}
13+
14+
legacy.get('/backup', async (_request, reply) => {
15+
const archive = Buffer.from(
16+
zipSync({
17+
'manifest.json': new TextEncoder().encode(JSON.stringify(manifest))
18+
})
19+
)
20+
reply.header('Content-Type', 'application/zip')
21+
return reply.send(archive)
22+
})
23+
24+
const response = await app.request('http://localhost/worker/backup')
25+
expect(response.status).toBe(200)
26+
expect(response.headers.get('content-type')).toContain('application/zip')
27+
28+
const body = new Uint8Array(await response.arrayBuffer())
29+
const entries = unzipSync(body)
30+
expect(strFromU8(entries['manifest.json'])).toContain('SubTracker')
31+
})
32+
33+
it('still serializes object payloads as json', async () => {
34+
const app = new Hono()
35+
const legacy = new LegacyFastifyApp(app, '/worker')
36+
37+
legacy.get('/json', async (_request, reply) => {
38+
return reply.send({
39+
ok: true
40+
})
41+
})
42+
43+
const response = await app.request('http://localhost/worker/json')
44+
expect(response.status).toBe(200)
45+
expect(response.headers.get('content-type')).toContain('application/json')
46+
await expect(response.json()).resolves.toEqual({
47+
ok: true
48+
})
49+
})
50+
})

apps/api/tests/unit/subtracker-backup.service.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ describe('subtracker backup service', () => {
225225

226226
const result = await createSubtrackerBackupArchive()
227227

228-
expect(result.filename).toContain('subtracker-backup-')
228+
expect(result.filename).toBe('subtracker-backup-2026-05-02T16-00-00.zip')
229229
expect(result.contentType).toBe('application/zip')
230230
const decoded = Buffer.from(result.buffer).toString('binary')
231231
expect(decoded.length).toBeGreaterThan(0)

apps/web/src/components/SubtrackerBackupModal.vue

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161

6262
<template v-else>
6363
<n-alert type="info" :show-icon="false">
64-
追加恢复时:同名标签会复用现有标签;订阅与支付记录按原始 ID 幂等跳过;系统设置是否覆盖由你单独选择
64+
追加恢复时:同名标签会复用现有标签;订阅与支付记录按备份中的唯一标识(CUID)幂等跳过;系统设置是否覆盖由你单独选择
6565
</n-alert>
6666
<div class="switch-row">
6767
<n-switch v-model:value="restoreSettings" />
@@ -78,11 +78,11 @@
7878
<strong>{{ preview.conflicts.existingTagNameCount }}</strong>
7979
</div>
8080
<div class="conflict-row">
81-
<span>现有同 ID 订阅:</span>
81+
<span>现有同唯一标识(CUID)订阅:</span>
8282
<strong>{{ preview.conflicts.existingSubscriptionIdCount }}</strong>
8383
</div>
8484
<div class="conflict-row">
85-
<span>现有同 ID 支付记录:</span>
85+
<span>现有同唯一标识(CUID)支付记录:</span>
8686
<strong>{{ preview.conflicts.existingPaymentRecordIdCount }}</strong>
8787
</div>
8888
</n-space>
@@ -156,6 +156,23 @@ function normalizePreviewErrorMessage(error: unknown) {
156156
return '备份预览失败'
157157
}
158158
159+
function buildRestoreSuccessMessage(result: {
160+
importedSubscriptions: number
161+
importedTags: number
162+
importedPaymentRecords: number
163+
importedLogos: number
164+
mode: 'replace' | 'append'
165+
}) {
166+
const importedTotal =
167+
result.importedSubscriptions + result.importedTags + result.importedPaymentRecords + result.importedLogos
168+
169+
if (result.mode === 'append' && importedTotal === 0) {
170+
return '未导入任何新数据,重复项已自动跳过'
171+
}
172+
173+
return `恢复完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个新标签,${result.importedPaymentRecords} 条支付记录,${result.importedLogos} 个 Logo`
174+
}
175+
159176
async function inspectFile() {
160177
if (!selectedFile.value) return
161178
@@ -186,9 +203,7 @@ async function commitImport() {
186203
mode: restoreMode.value,
187204
restoreSettings: restoreMode.value === 'replace' ? true : restoreSettings.value
188205
})
189-
message.success(
190-
`恢复完成:${result.importedSubscriptions} 条订阅,${result.importedTags} 个新标签,${result.importedPaymentRecords} 条支付记录,${result.importedLogos} 个 Logo`
191-
)
206+
message.success(buildRestoreSuccessMessage(result))
192207
emit('imported', {
193208
mode: result.mode,
194209
restoredSettings: result.restoredSettings

apps/web/tests/unit/components/settings-import-export.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ describe('settings import export copy', () => {
2424
expect(backupModal).not.toContain('title="导入 ZIP"')
2525
expect(backupModal).toContain('预览备份')
2626
expect(backupModal).toContain('确认恢复')
27+
expect(backupModal).toContain('备份 ZIP 无法解析')
28+
expect(backupModal).toContain('未导入任何新数据,重复项已自动跳过')
29+
expect(backupModal).toContain('按备份中的唯一标识(CUID)幂等跳过')
30+
expect(backupModal).toContain('现有同唯一标识(CUID)订阅')
31+
expect(backupModal).toContain('现有同唯一标识(CUID)支付记录')
2732
expect(backupModal).not.toContain('确认导入')
2833
})
2934
})

0 commit comments

Comments
 (0)