feat: add wallos db and zip import for worker

This commit is contained in:
SmileQWQ
2026-04-25 02:48:11 +08:00
parent 3645f20eda
commit 05e67c4bdd
15 changed files with 1140 additions and 346 deletions

View File

@@ -236,7 +236,30 @@ https://api.resend.com/emails
- 通知去重:回到 D1
- 导入预览 token回到 D1
- Logo 搜索缓存:仅保留进程内短缓存
### 3. Logo 搜索是 Lite 版
### 3. Wallos 导入能力
当前 Worker 分支支持:
- Wallos JSON 导入
- Wallos SQLite 数据库导入(`.db` / `.sqlite` / `.sqlite3`
- Wallos ZIP 备份导入
并且会尽量对齐 Wallos 自己的运行时语义:
-`auto_renew = true``next_payment` 已落后的订阅,会先按 Wallos 的周期推进逻辑修正日期,再判定状态
- ZIP 预览阶段会先处理其中可匹配的 Logo 资产
关于 ZIP Logo
- **启用 R2 时**
- 预览阶段会先把匹配到的 Logo 写入 R2 临时对象
- 确认导入时直接复用这些对象,不重复上传
- **未启用 R2 时**
- 仍可导入 ZIP 中的数据库内容
- 但会忽略 ZIP 中的 Logo并在预览中给出 warning
### 4. Logo 搜索是 Lite 版
Worker Lite 的 Logo 搜索只保留:
@@ -256,7 +279,7 @@ Worker Lite 的 Logo 搜索只保留:
- 搜索结果质量不如 Docker / main 分支
- 偶尔会混入不够理想的候选图
### 4. Cron 已拆成 Lite 版职责
### 5. Cron 已拆成 Lite 版职责
当前默认触发器是:
@@ -273,7 +296,7 @@ Worker Lite 的 Logo 搜索只保留:
- 只在规则命中的那一分钟发送提醒
- 相比旧的 `*/5` 版本,提醒更实时,但不再做 5 分钟补偿命中
### 5. 错误提示会明确说明 Worker 限制
### 6. 错误提示会明确说明 Worker 限制
前端遇到:
@@ -341,10 +364,9 @@ http://127.0.0.1:8787
- Telegram
- Resend 邮件
- AI 文本 / 图片识别
- Wallos JSON 导入
- Wallos JSON / SQLite / ZIP 导入
### 不支持
- 本地 OCR
- Wallos SQLite / ZIP 导入
- 原生 SMTP

View File

@@ -37,7 +37,7 @@
- **通知能力**Webhook、Resend 邮件、PushPlus、Telegram Bot
- **Logo 能力**上传R2、远程引用、网络搜索
- **AI 识别**:支持文本 / 图片识别后自动填充订阅信息
- **Wallos 导入**当前分支支持 JSON 导入
- **Wallos 导入**:支持 JSON、SQLite 数据库与 ZIP 备份导入;自动续订语义会按 Wallos 规则对齐
- **登录体验**:支持“记住我”、可配置的登录保留时长、默认密码修改提醒,以及登录失败限流保护
## Lite 版说明
@@ -115,7 +115,7 @@ npm test
常用仓库 Variables
- `WORKER_NAME_PREFIX`
- `ENABLE_R2`(默认关闭)
- `ENABLE_R2`(默认关闭;开启后可持久化 ZIP 导入的 Logo
## 工作流

View File

@@ -611,17 +611,30 @@ function isLocalLogoUrl(url?: string | null) {
return Boolean(url && url.startsWith('/static/logos/'))
}
function createLogoUrl(key: string) {
export function createLogoUrl(key: string) {
return `/static/logos/${encodeURIComponent(key)}`
}
async function writeLogoBuffer(buffer: Buffer | Uint8Array, contentType: string, logoSource: string) {
export function extractLogoStorageKey(logoUrl?: string | null) {
if (!isLocalLogoUrl(logoUrl)) return null
return decodeURIComponent(String(logoUrl).slice('/static/logos/'.length))
}
async function writeLogoBuffer(
buffer: Buffer | Uint8Array,
contentType: string,
logoSource: string,
options?: {
key?: string
}
) {
const bucket = getWorkerLogoBucket()
if (!bucket) {
throw new Error('对象存储未启用,无法保存 Logo 文件')
}
const filename = `logos/${Date.now()}-${crypto.randomBytes(6).toString('hex')}${extensionMap[contentType] ?? '.png'}`
const filename =
options?.key?.trim() || `logos/${Date.now()}-${crypto.randomBytes(6).toString('hex')}${extensionMap[contentType] ?? '.png'}`
await bucket.put(filename, buffer, {
httpMetadata: {
contentType
@@ -644,6 +657,26 @@ export async function saveImportedLogoBuffer(buffer: Buffer, contentType: string
return writeLogoBuffer(buffer, contentType, logoSource)
}
export async function saveImportedLogoBufferToKey(
buffer: Buffer,
contentType: string,
key: string,
logoSource = 'wallos-zip'
) {
if (!allowedTypes.has(contentType)) {
throw new Error('不支持的 Logo 图片类型')
}
if (!buffer.length) {
throw new Error('Logo 图片内容为空')
}
if (!key.trim()) {
throw new Error('Logo 存储 key 不能为空')
}
return writeLogoBuffer(buffer, contentType, logoSource, {
key
})
}
export async function saveUploadedLogo(input: LogoUploadInput) {
if (!allowedTypes.has(input.contentType)) {
throw new Error('仅支持 PNG、JPG、WEBP、SVG 图片')
@@ -814,3 +847,15 @@ export async function deleteLocalLogoFromLibrary(filename: string) {
deleted: true
}
}
export async function deleteLogoStorageObject(filename: string) {
const bucket = getWorkerLogoBucket()
if (!bucket) {
return
}
const safeName = filename.trim()
if (!safeName) {
return
}
await bucket.delete(safeName)
}

View File

@@ -64,7 +64,7 @@ function buildStorageCapabilities() {
kvEnabled: Boolean(getWorkerCache()),
r2Enabled: Boolean(getWorkerLogoBucket()),
logoStorageEnabled: Boolean(getWorkerLogoBucket()),
wallosImportMode: 'json-only'
wallosImportMode: 'json-db-zip'
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Prisma } from '@prisma/client'
import type { WallosImportInspectResultDto, WebhookEventType } from '@subtracker/shared'
import type { WebhookEventType } from '@subtracker/shared'
import { prisma } from '../db'
import { getRuntimeD1Database, isWorkerRuntime } from '../runtime'
@@ -137,7 +137,7 @@ export async function releaseNotificationDelivery(params: {
)
}
export async function storeImportPreview(token: string, preview: WallosImportInspectResultDto, ttlMs: number) {
export async function storeImportPreview<T>(token: string, preview: T, ttlMs: number) {
const expiresAt = new Date(Date.now() + ttlMs)
if (!getD1()) {
@@ -164,15 +164,24 @@ export async function storeImportPreview(token: string, preview: WallosImportIns
)
}
export async function getImportPreview(token: string) {
export async function getImportPreview<T>(
token: string,
options?: {
onExpired?: (payload: T | null) => Promise<void> | void
}
) {
if (!getD1()) {
const row = await prisma.importPreview.findUnique({ where: { token } })
if (!row) return null
if (row.expiresAt.getTime() <= Date.now()) {
await prisma.importPreview.deleteMany({ where: { token } })
const payload = row.previewJson as unknown as T
await deleteImportPreview(token, {
payload,
onDelete: options?.onExpired
})
return null
}
return row.previewJson as unknown as WallosImportInspectResultDto
return row.previewJson as unknown as T
}
const row = await d1First<ImportPreviewRow>(
@@ -185,14 +194,28 @@ export async function getImportPreview(token: string) {
if (!row) return null
if (new Date(row.expiresAt).getTime() <= Date.now()) {
await deleteImportPreview(token)
const payload = parseJsonValue<T>(row.previewJson)
await deleteImportPreview(token, {
payload,
onDelete: options?.onExpired
})
return null
}
return parseJsonValue<WallosImportInspectResultDto>(row.previewJson)
return parseJsonValue<T>(row.previewJson)
}
export async function deleteImportPreview(token: string) {
export async function deleteImportPreview<T>(
token: string,
options?: {
payload?: T | null
onDelete?: (payload: T | null) => Promise<void> | void
}
) {
if (options?.onDelete) {
await options.onDelete(options.payload ?? null)
}
if (!getD1()) {
await prisma.importPreview.deleteMany({ where: { token } })
return

View File

@@ -6,7 +6,9 @@ declare module 'adm-zip' {
}
export default class AdmZip {
constructor(input?: Buffer | string)
constructor(input?: Buffer | Uint8Array | ArrayBuffer | string)
addFile(entryName: string, content: Buffer | Uint8Array | ArrayBuffer): void
getEntries(): IZipEntry[]
toBuffer(): Buffer
}
}

View File

@@ -4,13 +4,16 @@ const { prismaMock, appendSubscriptionOrders } = vi.hoisted(() => ({
prismaMock: {
tag: {
findMany: vi.fn(),
createMany: vi.fn()
createMany: vi.fn(),
create: vi.fn()
},
subscription: {
createMany: vi.fn()
createMany: vi.fn(),
create: vi.fn()
},
subscriptionTag: {
createMany: vi.fn()
createMany: vi.fn(),
create: vi.fn()
}
},
appendSubscriptionOrders: vi.fn(async () => undefined)
@@ -45,83 +48,90 @@ describe('commitWallosImport', () => {
vi.setSystemTime(new Date('2026-04-25T00:00:00.000Z'))
prismaMock.tag.findMany.mockReset()
prismaMock.tag.createMany.mockReset()
prismaMock.tag.create.mockReset()
prismaMock.subscription.createMany.mockReset()
prismaMock.subscription.create.mockReset()
prismaMock.subscriptionTag.createMany.mockReset()
prismaMock.subscriptionTag.create.mockReset()
appendSubscriptionOrders.mockClear()
previewState.getImportPreview.mockReset()
previewState.deleteImportPreview.mockReset()
previewState.getImportPreview.mockResolvedValue({
importToken: 'token-1',
isWallos: true,
summary: {
fileType: 'json',
subscriptionsTotal: 2,
tagsTotal: 2,
usedTagsTotal: 2,
supportedSubscriptions: 2,
skippedSubscriptions: 0,
globalNotifyDays: 3,
zipLogoMatched: 0,
zipLogoMissing: 0
},
usedTags: [
{ sourceId: 1, name: 'Video', sortOrder: 1 },
{ sourceId: 2, name: 'Music', sortOrder: 2 }
],
tags: [],
subscriptionsPreview: [
{
sourceId: 1,
name: 'Netflix',
amount: 10,
currency: 'USD',
status: 'active',
autoRenew: true,
billingIntervalCount: 1,
billingIntervalUnit: 'year',
startDate: '2025-01-10',
nextRenewalDate: '2027-01-10',
notifyDaysBefore: 3,
webhookEnabled: true,
notes: '',
description: '',
websiteUrl: 'https://netflix.com/',
tagNames: ['Video'],
logoRef: null,
logoImportStatus: 'none',
warnings: ['价格 "$10" 的币种符号存在歧义,已默认按 USD 导入']
},
{
sourceId: 2,
name: 'Spotify',
amount: 15,
currency: 'USD',
status: 'active',
autoRenew: true,
billingIntervalCount: 1,
billingIntervalUnit: 'month',
startDate: '2026-04-02',
nextRenewalDate: '2026-05-02',
notifyDaysBefore: 3,
webhookEnabled: true,
notes: '',
description: '',
websiteUrl: 'https://spotify.com',
tagNames: ['Music'],
logoRef: null,
logoImportStatus: 'none',
warnings: []
}
],
warnings: []
})
})
afterEach(() => {
vi.useRealTimers()
})
it('batches imported tags, subscription tags and subscription order writes', async () => {
it('batches imported tags, subscriptions, joins, and order writes for normal imports', async () => {
previewState.getImportPreview.mockResolvedValue({
preview: {
importToken: 'token-1',
isWallos: true,
summary: {
fileType: 'db',
subscriptionsTotal: 2,
tagsTotal: 2,
usedTagsTotal: 2,
supportedSubscriptions: 2,
skippedSubscriptions: 0,
globalNotifyDays: 3,
zipLogoMatched: 0,
zipLogoMissing: 0
},
usedTags: [
{ sourceId: 1, name: 'Video', sortOrder: 1 },
{ sourceId: 2, name: 'Music', sortOrder: 2 }
],
tags: [],
subscriptionsPreview: [
{
sourceId: 1,
name: 'Netflix',
amount: 10,
currency: 'USD',
status: 'active',
autoRenew: true,
billingIntervalCount: 1,
billingIntervalUnit: 'year',
startDate: '2025-01-10',
nextRenewalDate: '2027-01-10',
notifyDaysBefore: 3,
webhookEnabled: true,
notes: '',
description: '',
websiteUrl: 'https://netflix.com/',
tagNames: ['Video'],
logoRef: null,
logoImportStatus: 'none',
warnings: []
},
{
sourceId: 2,
name: 'Spotify',
amount: 15,
currency: 'USD',
status: 'active',
autoRenew: true,
billingIntervalCount: 1,
billingIntervalUnit: 'month',
startDate: '2026-04-02',
nextRenewalDate: '2026-05-02',
notifyDaysBefore: 3,
webhookEnabled: true,
notes: '',
description: '',
websiteUrl: 'https://spotify.com/',
tagNames: ['Music'],
logoRef: null,
logoImportStatus: 'none',
warnings: []
}
],
warnings: []
},
logoManifest: {}
})
prismaMock.tag.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
@@ -142,14 +152,14 @@ describe('commitWallosImport', () => {
]
})
expect(prismaMock.subscription.createMany).toHaveBeenCalledTimes(1)
expect(prismaMock.subscription.createMany.mock.calls[0][0].data).toHaveLength(2)
const createdRows = prismaMock.subscription.createMany.mock.calls[0][0].data
const createdIds = createdRows.map((item: { id: string }) => item.id)
expect(createdRows[0]).toMatchObject({
currency: 'USD',
websiteUrl: 'https://netflix.com/',
nextRenewalDate: new Date('2027-01-10T00:00:00.000Z'),
status: 'active'
status: 'active',
logoUrl: null
})
expect(prismaMock.subscriptionTag.createMany).toHaveBeenCalledWith({
data: [
@@ -157,13 +167,82 @@ describe('commitWallosImport', () => {
{ subscriptionId: createdIds[1], tagId: 'tag_music' }
]
})
expect(appendSubscriptionOrders).toHaveBeenCalledTimes(1)
expect(appendSubscriptionOrders).toHaveBeenCalledWith(createdIds)
expect(previewState.deleteImportPreview).toHaveBeenCalledWith('token-1')
expect(result).toMatchObject({
importedTags: 2,
importedSubscriptions: 2,
skippedSubscriptions: 0
skippedSubscriptions: 0,
importedLogos: 0
})
expect(previewState.deleteImportPreview).toHaveBeenCalledWith('token-1')
})
it('reuses preview-stage zip logo manifest instead of re-uploading logos during commit', async () => {
previewState.getImportPreview.mockResolvedValue({
preview: {
importToken: 'token-zip',
isWallos: true,
summary: {
fileType: 'zip',
subscriptionsTotal: 1,
tagsTotal: 1,
usedTagsTotal: 1,
supportedSubscriptions: 1,
skippedSubscriptions: 0,
globalNotifyDays: 3,
zipLogoMatched: 1,
zipLogoMissing: 0
},
usedTags: [{ sourceId: 1, name: 'Video', sortOrder: 1 }],
tags: [],
subscriptionsPreview: [
{
sourceId: 1,
name: 'Netflix',
amount: 10,
currency: 'USD',
status: 'active',
autoRenew: true,
billingIntervalCount: 1,
billingIntervalUnit: 'year',
startDate: '2025-01-10',
nextRenewalDate: '2027-01-10',
notifyDaysBefore: 3,
webhookEnabled: true,
notes: '',
description: '',
websiteUrl: 'https://netflix.com/',
tagNames: ['Video'],
logoRef: 'abc.png',
logoImportStatus: 'ready-from-zip',
warnings: []
}
],
warnings: []
},
logoManifest: {
'abc.png': {
logoRef: 'abc.png',
r2Key: 'logos/imports/wallos/token-zip/abc.png',
logoUrl: '/static/logos/logos%2Fimports%2Fwallos%2Ftoken-zip%2Fabc.png',
contentType: 'image/png',
uploaded: true
}
}
})
prismaMock.tag.findMany.mockResolvedValue([{ id: 'tag_video', name: 'Video' }])
prismaMock.subscription.createMany.mockResolvedValue({ count: 1 })
prismaMock.subscriptionTag.createMany.mockResolvedValue({ count: 1 })
const result = await commitWallosImport({ importToken: 'token-zip' })
expect(prismaMock.subscription.createMany).toHaveBeenCalledTimes(1)
expect(prismaMock.subscription.createMany.mock.calls[0][0].data[0]).toMatchObject({
logoUrl: '/static/logos/logos%2Fimports%2Fwallos%2Ftoken-zip%2Fabc.png',
logoSource: 'wallos-zip'
})
expect(result.importedLogos).toBe(1)
expect(previewState.deleteImportPreview).toHaveBeenCalledWith('token-zip')
})
})

View File

@@ -1,14 +1,96 @@
import { createRequire } from 'node:module'
import path from 'node:path'
import AdmZip from 'adm-zip'
import initSqlJs from 'sql.js'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
mapWallosBillingInterval,
resolveWallosEffectiveNextPayment,
mapWallosSubscriptionStatus,
previewWallosImportFromBase64ForTest,
resolveWallosEffectiveNextPayment,
resolveWallosNotifyDays
} from '../../src/services/wallos-import.service'
function encodeJson(rows: unknown[]) {
return Buffer.from(JSON.stringify(rows), 'utf8').toString('base64')
const require = createRequire(import.meta.url)
async function createWallosFixtureBuffer() {
const SQL = await initSqlJs({
locateFile: (file: string) => path.resolve(path.dirname(require.resolve('sql.js/dist/sql-wasm.wasm')), file)
})
const db = new SQL.Database()
db.run(`
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL, "order" INTEGER DEFAULT 0, user_id INTEGER DEFAULT 1);
CREATE TABLE currencies (id INTEGER PRIMARY KEY, name TEXT NOT NULL, symbol TEXT NOT NULL, code TEXT NOT NULL, rate TEXT NOT NULL, user_id INTEGER DEFAULT 1);
CREATE TABLE cycles (id INTEGER PRIMARY KEY, days INTEGER NOT NULL, name TEXT NOT NULL);
CREATE TABLE frequencies (id INTEGER PRIMARY KEY, name INTEGER NOT NULL);
CREATE TABLE notification_settings (days INTEGER DEFAULT 0, user_id INTEGER DEFAULT 1);
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
logo TEXT,
price REAL NOT NULL,
currency_id INTEGER,
next_payment DATE,
cycle INTEGER,
frequency INTEGER,
notes TEXT,
payment_method_id INTEGER,
payer_user_id INTEGER,
category_id INTEGER,
notify BOOLEAN DEFAULT false,
url VARCHAR(255),
inactive BOOLEAN DEFAULT false,
notify_days_before INTEGER DEFAULT 0,
user_id INTEGER DEFAULT 1,
cancellation_date DATE,
replacement_subscription_id INTEGER DEFAULT NULL,
start_date INTEGER DEFAULT NULL,
auto_renew INTEGER DEFAULT 1
);
`)
db.run(`
INSERT INTO categories (id, name, "order") VALUES
(1, 'No category', 1),
(2, 'VPS', 2),
(3, 'UnusedTag', 3);
INSERT INTO currencies (id, name, symbol, code, rate) VALUES
(1, '人民币', '¥', 'CNY', '1'),
(2, 'US Dollar', '$', 'USD', '7');
INSERT INTO cycles (id, days, name) VALUES
(1, 30, 'Monthly'),
(2, 365, 'Yearly');
INSERT INTO frequencies (id, name) VALUES
(1, 1),
(2, 2);
INSERT INTO notification_settings (days) VALUES (3);
INSERT INTO subscriptions
(id, name, logo, price, currency_id, next_payment, cycle, frequency, notes, category_id, notify, url, inactive, notify_days_before, start_date, auto_renew)
VALUES
(10, 'Test VPS', 'abc.png', 10, 2, '2025-01-10', 2, 1, 'note', 2, 1, 'example.com', 0, -1, 1736467200, 1),
(11, 'No category sub', NULL, 5, 1, '2026-07-01', 1, 1, '', 1, 0, NULL, 0, NULL, NULL, 0);
`)
const buffer = Buffer.from(db.export())
db.close()
return buffer
}
function encodeBase64(buffer: Buffer) {
return buffer.toString('base64')
}
async function createWallosZipBase64() {
const zip = new AdmZip()
zip.addFile('backup/db/wallos.db', await createWallosFixtureBuffer())
zip.addFile('backup/images/abc.png', Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
return zip.toBuffer().toString('base64')
}
describe('wallos import helpers', () => {
@@ -21,7 +103,7 @@ describe('wallos import helpers', () => {
vi.useRealTimers()
})
it('maps standard billing intervals', () => {
it('maps standard billing intervals and wallos status helpers', () => {
expect(mapWallosBillingInterval(30, 2)).toMatchObject({
billingIntervalCount: 2,
billingIntervalUnit: 'month',
@@ -32,9 +114,7 @@ describe('wallos import helpers', () => {
billingIntervalCount: 45,
billingIntervalUnit: 'day'
})
})
it('maps status and notify config', () => {
expect(mapWallosSubscriptionStatus({ inactive: 1, cancellationDate: null, nextPayment: '2026-06-01' })).toBe('paused')
expect(mapWallosSubscriptionStatus({ inactive: 0, cancellationDate: '2026-04-01', nextPayment: '2026-06-01' })).toBe(
'cancelled'
@@ -63,43 +143,14 @@ describe('wallos import helpers', () => {
webhookEnabled: true,
notifyDaysBefore: 3
})
expect(resolveWallosNotifyDays({ notify: 1, notifyDaysBefore: 0, globalNotifyDays: 3 })).toEqual({
webhookEnabled: true,
notifyDaysBefore: 3
})
})
it('only accepts wallos json in worker lite and keeps used tags only', async () => {
it('supports sqlite db imports with wallos semantics and used-tag filtering', async () => {
const preview = await previewWallosImportFromBase64ForTest(
{
filename: 'wallos.json',
contentType: 'application/json',
base64: encodeJson([
{
Name: 'Test VPS',
Price: '$10.00',
Category: 'VPS',
'Next Payment': '2026-06-01',
Notifications: 'enabled',
Renewal: 'automatic',
Notes: 'note',
URL: 'https://example.com/a'
},
{
Name: 'No category sub',
Price: '¥5',
Category: 'No category',
'Next Payment': '2026-07-01',
Notifications: 'disabled',
Renewal: 'manual'
},
{
Name: '',
Price: '$1',
Category: 'UnusedTag',
'Next Payment': ''
}
])
filename: 'wallos.db',
contentType: 'application/octet-stream',
base64: encodeBase64(await createWallosFixtureBuffer())
},
{
defaultNotifyDays: 3,
@@ -107,20 +158,22 @@ describe('wallos import helpers', () => {
}
)
expect(preview.isWallos).toBe(true)
expect(preview.summary.fileType).toBe('json')
expect(preview.summary.fileType).toBe('db')
expect(preview.usedTags.map((item) => item.name)).toEqual(['VPS'])
expect(preview.summary.usedTagsTotal).toBe(1)
expect(preview.summary.skippedSubscriptions).toBe(1)
expect(preview.summary.zipLogoMatched).toBe(0)
const categorized = preview.subscriptionsPreview.find((item) => item.name === 'Test VPS')
expect(categorized).toMatchObject({
autoRenew: true,
websiteUrl: 'https://example.com/',
nextRenewalDate: '2027-01-10',
status: 'active',
notifyDaysBefore: 3,
tagNames: ['VPS']
})
const uncategorized = preview.subscriptionsPreview.find((item) => item.name === 'No category sub')
expect(uncategorized?.tagNames).toEqual([])
const categorized = preview.subscriptionsPreview.find((item) => item.name === 'Test VPS')
expect(categorized?.tagNames).toEqual(['VPS'])
expect(categorized?.autoRenew).toBe(true)
expect(categorized?.logoImportStatus).toBe('none')
})
it('normalizes wallos json preview with wallos semantics and warnings', async () => {
@@ -128,18 +181,20 @@ describe('wallos import helpers', () => {
{
filename: 'wallos.json',
contentType: 'application/json',
base64: encodeJson([
{
Name: 'Legacy Auto Renew',
Price: '$10',
Category: 'Video',
'Payment Cycle': 'Yearly',
'Next Payment': '2025-01-10',
Renewal: 'Automatic',
URL: 'netflix.com',
Notes: ''
}
])
base64: Buffer.from(
JSON.stringify([
{
Name: 'Legacy Auto Renew',
Price: '$10',
Category: 'Video',
'Payment Cycle': 'Yearly',
'Next Payment': '2025-01-10',
Renewal: 'Automatic',
URL: 'netflix.com',
Notes: ''
}
])
).toString('base64')
},
{
defaultNotifyDays: 3,
@@ -148,7 +203,6 @@ describe('wallos import helpers', () => {
)
const item = preview.subscriptionsPreview[0]
expect(item).toBeDefined()
expect(item?.nextRenewalDate).toBe('2027-01-10')
expect(item?.status).toBe('active')
expect(item?.websiteUrl).toBe('https://netflix.com/')
@@ -158,10 +212,27 @@ describe('wallos import helpers', () => {
'Wallos JSON 不包含 start_date已使用 Next Payment 代填开始日期'
])
)
expect(preview.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining('网址 "netflix.com" 缺少协议')
])
})
it('allows zip imports without R2 and warns that zip logos are ignored', async () => {
const preview = await previewWallosImportFromBase64ForTest(
{
filename: 'wallos-backup.zip',
contentType: 'application/zip',
base64: await createWallosZipBase64()
},
{
defaultNotifyDays: 3,
baseCurrency: 'CNY'
}
)
expect(preview.summary.fileType).toBe('zip')
expect(preview.summary.zipLogoMatched).toBe(1)
expect(preview.warnings).toEqual(expect.arrayContaining([expect.stringContaining('当前 Worker 未启用 R2')]))
expect(preview.subscriptionsPreview.find((item) => item.name === 'Test VPS')).toMatchObject({
logoImportStatus: 'none',
logoRef: null
})
})
})

View File

@@ -2,11 +2,11 @@
<n-modal :show="show" preset="card" title="导入 Wallos 数据" style="width: min(1080px, calc(100vw - 24px))" @update:show="handleShowUpdate">
<n-space vertical :size="16" style="width: 100%">
<n-alert type="info" :show-icon="false">
Cloudflare Worker 版本当前仅支持上传 Wallos JSON 导出文件仍会只导入实际被订阅使用到的标签
支持上传 Wallos JSONSQLite 数据库或 ZIP 当前只导入实际被订阅使用到的标签
</n-alert>
<n-space align="center" wrap>
<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden-input" @change="handleFileChange" />
<input ref="fileInputRef" type="file" accept=".json,.db,.sqlite,.sqlite3,.zip,application/octet-stream,application/json,application/zip" class="hidden-input" @change="handleFileChange" />
<n-button @click="pickFile">选择文件</n-button>
<span class="file-name">{{ selectedFileName || '未选择文件' }}</span>
<n-button type="primary" :disabled="!selectedFile" :loading="inspecting" @click="inspectFile">生成预览</n-button>
@@ -38,8 +38,8 @@
</n-grid-item>
<n-grid-item>
<n-card size="small">
<div class="summary-label">Logo 导入</div>
<div class="summary-value">Cloudflare Worker 不支持</div>
<div class="summary-label">ZIP Logo 匹配</div>
<div class="summary-value">{{ preview.summary.zipLogoMatched }}/{{ preview.summary.zipLogoMatched + preview.summary.zipLogoMissing }}</div>
</n-card>
</n-grid-item>
</n-grid>
@@ -160,8 +160,8 @@ const subscriptionColumns = [
render: (row: WallosImportSubscriptionPreview) =>
({
none: '无',
'pending-file-match': '不支持',
'ready-from-zip': '不支持'
'pending-file-match': '待匹配',
'ready-from-zip': 'ZIP 可导入'
})[row.logoImportStatus]
}
]
@@ -250,8 +250,8 @@ function unitText(unit: WallosImportSubscriptionPreview['billingIntervalUnit'])
function fileTypeText(type: WallosImportInspectResult['summary']['fileType']) {
return {
json: 'JSON',
db: '已停用',
zip: '已停用'
db: 'SQLite',
zip: 'ZIP'
}[type]
}
</script>
@@ -278,6 +278,15 @@ function fileTypeText(type: WallosImportInspectResult['summary']['fileType']) {
color: var(--app-text-strong);
}
.warning-list {
margin-top: 12px;
margin-bottom: 0;
padding-left: 18px;
color: var(--app-text-secondary);
display: grid;
gap: 8px;
}
.warning-header {
display: flex;
align-items: center;
@@ -287,12 +296,4 @@ function fileTypeText(type: WallosImportInspectResult['summary']['fileType']) {
font-size: 13px;
}
.warning-list {
margin-top: 12px;
margin-bottom: 0;
padding-left: 18px;
color: var(--app-text-secondary);
display: grid;
gap: 8px;
}
</style>

View File

@@ -11,7 +11,7 @@
当前运行时Cloudflare Worker
KV 不使用
R2 {{ settingsForm.storageCapabilities.r2Enabled ? '已启用' : '未启用,仅支持远程 Logo 引用' }}
Wallos 导入模式 JSON
Wallos 导入模式JSON / SQLite / ZIP
</n-alert>
<n-grid :cols="gridCols" :x-gap="12" :y-gap="12">
@@ -521,9 +521,9 @@
<n-space vertical style="width: 100%">
<n-alert type="info" :show-icon="false">
可导出全部订阅为 CSV / JSON也可在这里导入 Wallos 数据
当前 Cloudflare Worker 版本支持 JSON 导入
当前 Cloudflare Worker 版本支持 JSONSQLite ZIP 导入
<template v-if="!settingsForm.storageCapabilities.r2Enabled">
当前未启用 R2Logo 只支持远程引用不支持本地库持久化
当前未启用 R2ZIP 中的 Logo 会被忽略但仍可导入其中的数据库内容
</template>
</n-alert>
<n-space wrap>
@@ -689,7 +689,7 @@ const settingsForm = reactive<Settings>({
kvEnabled: false,
r2Enabled: false,
logoStorageEnabled: false,
wallosImportMode: 'json-only'
wallosImportMode: 'json-db-zip'
},
aiConfig: {
...DEFAULT_AI_CONFIG,

View File

@@ -60,7 +60,7 @@ export const useAppStore = defineStore('app', () => {
kvEnabled: false,
r2Enabled: false,
logoStorageEnabled: false,
wallosImportMode: 'json-only'
wallosImportMode: 'json-db-zip'
},
aiConfig: {
...DEFAULT_AI_CONFIG,

View File

@@ -227,7 +227,7 @@ export interface StorageCapabilities {
kvEnabled: boolean
r2Enabled: boolean
logoStorageEnabled: boolean
wallosImportMode: 'json-only'
wallosImportMode: 'json-only' | 'json-db-zip'
}
export interface Settings {

View File

@@ -10,7 +10,7 @@ describe('worker capabilities helpers', () => {
kvEnabled: true,
r2Enabled: true,
logoStorageEnabled: true,
wallosImportMode: 'json-only'
wallosImportMode: 'json-db-zip'
}
})
).toBe(true)
@@ -25,7 +25,7 @@ describe('worker capabilities helpers', () => {
kvEnabled: false,
r2Enabled: false,
logoStorageEnabled: false,
wallosImportMode: 'json-only'
wallosImportMode: 'json-db-zip'
}
})
).toBe(false)

View File

@@ -192,7 +192,7 @@ export const StorageCapabilitiesSchema = z.object({
kvEnabled: z.boolean().default(false),
r2Enabled: z.boolean().default(false),
logoStorageEnabled: z.boolean().default(false),
wallosImportMode: z.literal('json-only').default('json-only')
wallosImportMode: z.enum(['json-only', 'json-db-zip']).default('json-db-zip')
})
export const SettingsSchema = z.object({