mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-02 06:59:30 +08:00
feat: add wallos db and zip import for worker
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
4
apps/api/src/types/adm-zip.d.ts
vendored
4
apps/api/src/types/adm-zip.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 的 JSON、SQLite 数据库或 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>
|
||||
|
||||
@@ -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 版本支持 JSON、SQLite 与 ZIP 导入。
|
||||
<template v-if="!settingsForm.storageCapabilities.r2Enabled">
|
||||
当前未启用 R2,Logo 只支持远程引用,不支持本地库持久化。
|
||||
当前未启用 R2,ZIP 中的 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user