878 lines
33 KiB
TypeScript
878 lines
33 KiB
TypeScript
import { repaymentMonth, date } from './validation';
|
|
import { BACKUP_ZIP_VERSION } from './backup-format';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { metalConfig, metalPriceInput, storedMetalPurity } from './metals';
|
|
import { scheduleInput } from './schedules';
|
|
import { movementDeltas } from './movement';
|
|
import { pairedReasons } from './validation';
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Body,
|
|
Req,
|
|
Res,
|
|
BadRequestException,
|
|
ConflictException,
|
|
UploadedFile,
|
|
UseInterceptors,
|
|
OnModuleDestroy,
|
|
OnModuleInit,
|
|
} from '@nestjs/common';
|
|
import { Response } from 'express';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { diskStorage } from 'multer';
|
|
import { tmpdir } from 'node:os';
|
|
import { unlink, readdir, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { archiveBackup, readBackupZip, MAX_UPLOAD_BYTES } from './zip';
|
|
import { z } from 'zod';
|
|
import { Prisma } from '@prisma/client';
|
|
import Decimal from 'decimal.js';
|
|
import { Database } from './database';
|
|
import { UserRequest } from './auth';
|
|
import {
|
|
positionInput,
|
|
amount,
|
|
positionMeta,
|
|
currency,
|
|
revisionInput,
|
|
rateInput,
|
|
hiddenMenus,
|
|
accountGroupOrder,
|
|
sessionHours,
|
|
overviewCards,
|
|
defaultOverviewCards,
|
|
transferInput,
|
|
} from './validation';
|
|
import { createHash } from 'node:crypto';
|
|
import { toBusinessDate } from './validation';
|
|
import { iconName, validateStoredIcon } from './icons';
|
|
import { persistIconFile } from './icon-files';
|
|
import { day, businessTime } from './calculation';
|
|
const timestamp = z.iso
|
|
.datetime()
|
|
.refine(
|
|
(s) => s >= '1900-01-01T00:00:00.000Z' && new Date(s).getTime() <= Date.now() + 60000,
|
|
'创建和更新时间无效',
|
|
);
|
|
const record = positionMeta
|
|
.extend({
|
|
groupName: z.string().max(60),
|
|
included: z.boolean(),
|
|
iconId: z.string().uuid().nullable(),
|
|
notes: z.string().max(2000),
|
|
archived: z.boolean(),
|
|
hidden: z.boolean(),
|
|
lastRepaymentMonth: repaymentMonth.optional(),
|
|
lastBookedDate: date.nullable().optional(),
|
|
metalType: z.enum(['gold', 'silver']).nullable(),
|
|
metalGrams: amount.nullable(),
|
|
metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(),
|
|
metalPurity: storedMetalPurity,
|
|
autoValuation: z.boolean(),
|
|
kind: z.enum(['account', 'asset', 'debt']),
|
|
side: z.enum(['asset', 'liability']),
|
|
currency,
|
|
id: z.string().uuid(),
|
|
importedFromId: z.string().uuid().nullable(),
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
revisions: z.array(
|
|
revisionInput.extend({
|
|
id: z.string().uuid(),
|
|
notes: z.string().max(2000),
|
|
reason: revisionInput.shape.reason.unwrap(),
|
|
date: revisionInput.shape.date.refine((s) => s.length === 16, '备份业务时间必须精确到分钟'),
|
|
sequence: z.number().int().positive().max(2147483647),
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
}),
|
|
),
|
|
})
|
|
.strict();
|
|
const backupSchema = z
|
|
.object({
|
|
format: z.literal('worthpath'),
|
|
version: z.literal(BACKUP_ZIP_VERSION, { error: '备份数据必须来自当前 ZIP v9 格式' }),
|
|
exportedAt: z.iso.datetime(),
|
|
baseCurrency: currency,
|
|
currencies: z.array(currency).max(10),
|
|
preferences: z
|
|
.object({
|
|
hiddenMenus: hiddenMenus,
|
|
accountGroupOrder: accountGroupOrder,
|
|
sessionHours: sessionHours,
|
|
requireHiddenPassword: z.boolean(),
|
|
overviewCards: overviewCards,
|
|
includeIndependentAssets: z.boolean(),
|
|
showNotes: z.boolean(),
|
|
idleMinutes: z.number().int().min(0).max(1440),
|
|
})
|
|
.strict(),
|
|
icons: z.array(
|
|
z
|
|
.object({
|
|
id: z.string().uuid(),
|
|
name: iconName,
|
|
shared: z.boolean(),
|
|
image: z.string().max(3 * 1024 * 1024),
|
|
hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
})
|
|
.strict(),
|
|
),
|
|
transfers: z.array(
|
|
transferInput.safeExtend({
|
|
id: z.string().uuid(),
|
|
importedFromId: z.string().uuid().nullable(),
|
|
operation: transferInput.shape.operation.unwrap(),
|
|
fee: transferInput.shape.fee.unwrap(),
|
|
notes: z.string().max(2000),
|
|
date: transferInput.shape.date.refine((s) => s.length === 16),
|
|
sourceRevisionId: z.string().uuid(),
|
|
targetRevisionId: z.string().uuid(),
|
|
sourceCurrency: currency,
|
|
targetCurrency: currency,
|
|
createdAt: timestamp,
|
|
}),
|
|
),
|
|
schedules: z.array(
|
|
scheduleInput.safeExtend({
|
|
id: z.string().uuid(),
|
|
importedFromId: z.string().uuid().nullable(),
|
|
enabled: z.boolean(),
|
|
completed: z.boolean(),
|
|
targetId: z.string().uuid().nullable(),
|
|
received: amount,
|
|
notes: z.string().max(2000),
|
|
}),
|
|
),
|
|
metalPrices: z.array(
|
|
metalPriceInput.extend({
|
|
source: z.enum(['manual', 'goldapi']),
|
|
quotedAt: timestamp,
|
|
}),
|
|
),
|
|
positions: z.array(record),
|
|
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
|
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
|
})
|
|
.strict();
|
|
export type Backup = z.infer<typeof backupSchema>;
|
|
export function validateBackup(raw: unknown) {
|
|
const b = backupSchema.parse(raw),
|
|
ids = new Map(b.positions.map((p) => [p.id, p]));
|
|
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
|
|
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
|
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
|
|
const quoteKeys = b.metalPrices.map((q) => q.metalType + q.currency + q.date);
|
|
if (new Set(quoteKeys).size !== quoteKeys.length) throw new BadRequestException('重复贵金属报价');
|
|
const iconIds = new Set(b.icons.map((i) => i.id));
|
|
if (
|
|
iconIds.size !== b.icons.length ||
|
|
b.positions.some((p) => p.iconId && !iconIds.has(p.iconId))
|
|
)
|
|
throw new BadRequestException('图标关联无效');
|
|
const revisionIds = new Set<string>();
|
|
for (const p of b.positions) {
|
|
if (p.lastBookedDate && p.kind !== 'account')
|
|
throw new BadRequestException('仅账户支持当天记账标记');
|
|
if (p.lastRepaymentMonth && (p.kind !== 'account' || p.side !== 'liability'))
|
|
throw new BadRequestException('还款月份仅适用于欠款账户');
|
|
if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) {
|
|
if (p.kind !== 'asset' || p.category !== 'gold')
|
|
throw new BadRequestException('贵金属估价关联无效');
|
|
metalConfig.parse({
|
|
metalType: p.metalType,
|
|
metalGrams: p.metalGrams,
|
|
metalCostPerGram: p.metalCostPerGram,
|
|
autoValuation: p.autoValuation,
|
|
});
|
|
}
|
|
positionInput.parse({
|
|
name: p.name,
|
|
category: p.category,
|
|
groupName: p.groupName,
|
|
kind: p.kind,
|
|
side: p.side,
|
|
currency: p.currency,
|
|
notes: p.notes,
|
|
archived: p.archived,
|
|
hidden: p.hidden,
|
|
included: p.included,
|
|
amount: '0',
|
|
date: p.revisions[0]?.date || '1900-01-01',
|
|
});
|
|
const sequences = new Set<number>();
|
|
for (const r of p.revisions) {
|
|
if (revisionIds.has(r.id) || (r.sequence !== undefined && sequences.has(r.sequence)))
|
|
throw new BadRequestException('重复历史记录');
|
|
if (r.sequence !== undefined) sequences.add(r.sequence);
|
|
revisionIds.add(r.id);
|
|
}
|
|
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
|
}
|
|
for (const p of b.positions)
|
|
if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-')))
|
|
throw new BadRequestException('仅账户支持负余额');
|
|
const planIds = new Set<string>();
|
|
for (const plan of b.schedules) {
|
|
const source = ids.get(plan.sourceId),
|
|
target = plan.targetId ? ids.get(plan.targetId) : null;
|
|
if (
|
|
planIds.has(plan.importedFromId || plan.id) ||
|
|
!source ||
|
|
source.kind !== 'account' ||
|
|
(plan.operation === 'expense' && source.side !== 'asset') ||
|
|
(plan.operation === 'expense'
|
|
? !!plan.targetId
|
|
: !target ||
|
|
target.kind !== 'account' ||
|
|
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
|
|
)
|
|
throw new BadRequestException('计划账户关联无效');
|
|
planIds.add(plan.importedFromId || plan.id);
|
|
}
|
|
const transferIds = new Set<string>(),
|
|
usedRevisions = new Set<string>();
|
|
for (const t of b.transfers) {
|
|
const source = ids.get(t.sourceId),
|
|
target = ids.get(t.targetId);
|
|
const origin = t.importedFromId || t.id;
|
|
if (
|
|
transferIds.has(origin) ||
|
|
!source ||
|
|
!target ||
|
|
source.kind !== 'account' ||
|
|
(t.operation !== 'transfer' && source.side !== 'asset') ||
|
|
(t.operation === 'transfer'
|
|
? target.kind !== 'account'
|
|
: target.kind !== 'debt' ||
|
|
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
|
|
source.currency !== t.sourceCurrency ||
|
|
target.currency !== t.targetCurrency
|
|
)
|
|
throw new BadRequestException('转账关联无效');
|
|
transferIds.add(origin);
|
|
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
|
throw new BadRequestException('同币种转账金额不一致');
|
|
const deltas = movementDeltas(
|
|
t.operation,
|
|
t.amount,
|
|
t.received,
|
|
t.fee,
|
|
source.side,
|
|
target.side,
|
|
);
|
|
for (const [p, revId, reason, delta] of [
|
|
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
|
|
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
|
|
] as const) {
|
|
const ordered = [...p.revisions].sort(
|
|
(a, b) =>
|
|
a.date.localeCompare(b.date) ||
|
|
(a.sequence || 0) - (b.sequence || 0) ||
|
|
a.createdAt.localeCompare(b.createdAt),
|
|
);
|
|
const index = ordered.findIndex((r) => r.id === revId),
|
|
current = ordered[index];
|
|
if (
|
|
usedRevisions.has(revId) ||
|
|
index < 0 ||
|
|
!current ||
|
|
current.reason !== reason ||
|
|
current.date !== t.date ||
|
|
!new Decimal(current.amount).minus(ordered[index - 1]?.amount || '0').eq(delta)
|
|
)
|
|
throw new BadRequestException('转账历史与双方金额不一致');
|
|
usedRevisions.add(revId);
|
|
}
|
|
}
|
|
if (
|
|
b.positions.some((p) =>
|
|
p.revisions.some((r) => pairedReasons.includes(r.reason) && !usedRevisions.has(r.id)),
|
|
)
|
|
)
|
|
throw new BadRequestException('缺少配对转账记录');
|
|
const links = new Set<string>();
|
|
for (const l of b.links) {
|
|
const s = ids.get(l.sourceId),
|
|
t = ids.get(l.targetId),
|
|
key = l.sourceId + l.targetId;
|
|
if (!s || !t || s.kind !== 'debt' || t.kind === 'debt' || s.id === t.id || links.has(key))
|
|
throw new BadRequestException('关联关系无效');
|
|
links.add(key);
|
|
}
|
|
const rates = new Set<string>();
|
|
for (const r of b.rates) {
|
|
const key = `${r.currency}/${r.baseCurrency}/${r.date}`;
|
|
if (rates.has(key)) throw new BadRequestException('重复汇率');
|
|
rates.add(key);
|
|
if (!b.currencies.includes(r.currency) || !b.currencies.includes(r.baseCurrency))
|
|
throw new BadRequestException('币种清单不完整');
|
|
}
|
|
if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整');
|
|
return b;
|
|
}
|
|
|
|
@Injectable()
|
|
export class BackupBusinessService implements OnModuleDestroy, OnModuleInit {
|
|
private uploads = new Map<
|
|
string,
|
|
{ sessionId: string; userId: string; path: string; expires: number }
|
|
>();
|
|
private cleaner = setInterval(() => void this.prune(), 60000).unref();
|
|
private async prune(all = false) {
|
|
for (const [token, v] of this.uploads)
|
|
if (all || v.expires < Date.now()) {
|
|
this.uploads.delete(token);
|
|
await unlink(v.path).catch(() => {});
|
|
}
|
|
// Remove only this application's expired uploads, including files left by a restart.
|
|
for (const name of await readdir(tmpdir()).catch(() => [])) {
|
|
if (!/^worthpath-import-[a-f0-9-]{36}\.zip$/.test(name)) continue;
|
|
const path = join(tmpdir(), name),
|
|
info = await stat(path).catch(() => null);
|
|
if (info && Date.now() - info.mtimeMs > 15 * 60000) await unlink(path).catch(() => {});
|
|
}
|
|
}
|
|
async onModuleDestroy() {
|
|
clearInterval(this.cleaner);
|
|
await this.prune(true);
|
|
}
|
|
private async uploadedData(path: string) {
|
|
return readBackupZip(path);
|
|
}
|
|
constructor(private db: Database) {}
|
|
async snapshot(userId: string) {
|
|
return this.fingerprint(await this.data(userId));
|
|
}
|
|
async inspectUpload(r: UserRequest, token: string) {
|
|
const v = this.uploads.get(token);
|
|
if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now())
|
|
throw new BadRequestException('导入预览已失效,请重新上传备份');
|
|
const data = await this.uploadedData(v.path);
|
|
return { data, preview: await this.preview(r, data) };
|
|
}
|
|
async restoreUpload(r: UserRequest, token: string) {
|
|
const prepared = await this.inspectUpload(r, token);
|
|
return this.restore(r, { confirmed: true, backup: prepared.data });
|
|
}
|
|
private async data(
|
|
userId: string,
|
|
client: Database | Prisma.TransactionClient = this.db,
|
|
): Promise<Backup> {
|
|
const [user, ps, rates] = await Promise.all([
|
|
client.user.findUniqueOrThrow({
|
|
where: { id: userId },
|
|
select: {
|
|
baseCurrency: true,
|
|
hiddenMenus: true,
|
|
showNotes: true,
|
|
idleMinutes: true,
|
|
accountGroupOrder: true,
|
|
sessionHours: true,
|
|
requireHiddenPassword: true,
|
|
overviewCards: true,
|
|
includeIndependentAssets: true,
|
|
},
|
|
}),
|
|
client.position.findMany({
|
|
where: { userId },
|
|
include: {
|
|
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
|
outgoing: true,
|
|
},
|
|
}),
|
|
client.exchangeRate.findMany({ where: { userId } }),
|
|
]);
|
|
const positions = ps.map((p) => ({
|
|
id: p.id,
|
|
iconId: p.iconId,
|
|
importedFromId: p.importedFromId,
|
|
name: p.name,
|
|
kind: p.kind,
|
|
side: p.side,
|
|
category: p.category,
|
|
groupName: p.groupName,
|
|
currency: p.currency,
|
|
notes: p.notes,
|
|
archived: p.archived,
|
|
hidden: p.hidden,
|
|
included: p.included,
|
|
lastRepaymentMonth: p.lastRepaymentMonth,
|
|
lastBookedDate: p.lastBookedDate,
|
|
metalType: p.metalType,
|
|
metalGrams: p.metalGrams?.toString() ?? null,
|
|
metalCostPerGram: p.metalCostPerGram?.toString() ?? null,
|
|
metalPurity: p.metalPurity.toString(),
|
|
autoValuation: p.autoValuation,
|
|
createdAt: p.createdAt.toISOString(),
|
|
updatedAt: p.updatedAt.toISOString(),
|
|
revisions: p.revisions.map((r) => ({
|
|
id: r.id,
|
|
sequence: r.sequence,
|
|
amount: r.amount.toString(),
|
|
date: businessTime(r.effectiveDate),
|
|
notes: r.notes,
|
|
reason: r.reason,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
})),
|
|
}));
|
|
const icons = await client.icon.findMany({
|
|
where: {
|
|
OR: [
|
|
{ ownerId: userId },
|
|
{
|
|
id: { in: ps.flatMap((p) => (p.iconId ? [p.iconId] : [])) },
|
|
OR: [{ shared: true }, { ownerId: userId }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const transfers = await client.transfer.findMany({ where: { userId } });
|
|
const schedules = await client.schedule.findMany({ where: { userId } });
|
|
return backupSchema.parse({
|
|
format: 'worthpath',
|
|
version: BACKUP_ZIP_VERSION,
|
|
exportedAt: new Date().toISOString(),
|
|
baseCurrency: user.baseCurrency,
|
|
preferences: {
|
|
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
|
showNotes: user.showNotes,
|
|
idleMinutes: user.idleMinutes,
|
|
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
|
|
sessionHours: user.sessionHours,
|
|
requireHiddenPassword: user.requireHiddenPassword,
|
|
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
|
|
includeIndependentAssets: user.includeIndependentAssets,
|
|
},
|
|
currencies: [
|
|
...new Set([
|
|
user.baseCurrency,
|
|
...ps.map((p) => p.currency),
|
|
...rates.flatMap((r) => [r.currency, r.baseCurrency]),
|
|
]),
|
|
],
|
|
icons: icons.map((i) => ({
|
|
id: i.id,
|
|
name: i.name,
|
|
shared: i.shared,
|
|
hash: i.hash,
|
|
image: Buffer.from(i.data).toString('base64'),
|
|
})),
|
|
transfers: transfers.map(({ userId, effectiveDate, ...t }) => ({
|
|
...t,
|
|
amount: t.amount.toString(),
|
|
received: t.received.toString(),
|
|
fee: t.fee.toString(),
|
|
date: businessTime(effectiveDate),
|
|
createdAt: t.createdAt.toISOString(),
|
|
})),
|
|
schedules: schedules.map(({ userId, ...v }) => ({
|
|
...v,
|
|
amount: v.amount.toString(),
|
|
received: v.received.toString(),
|
|
nextAt: businessTime(v.nextAt),
|
|
})),
|
|
metalPrices: (await client.metalPrice.findMany({ where: { userId } })).map(
|
|
({ id, userId, date, quotedAt, price, ...v }) => ({
|
|
...v,
|
|
date: day(date),
|
|
quotedAt: quotedAt.toISOString(),
|
|
price: price.toString(),
|
|
}),
|
|
),
|
|
positions,
|
|
links: ps.flatMap((p) =>
|
|
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
|
),
|
|
rates: rates.map((r) => ({
|
|
currency: r.currency,
|
|
baseCurrency: r.baseCurrency,
|
|
date: day(r.date),
|
|
rate: r.rate.toString(),
|
|
source: r.source,
|
|
})),
|
|
});
|
|
}
|
|
async download(r: UserRequest, res: Response, expectedFingerprint?: string) {
|
|
const b = await this.db.$transaction(
|
|
async (tx) => {
|
|
const b = await this.data(r.userId, tx);
|
|
if (expectedFingerprint && this.fingerprint(b) !== expectedFingerprint)
|
|
throw new ConflictException('账目已变化,请重新确认备份导出');
|
|
await tx.session.update({
|
|
where: { id: r.sessionId },
|
|
data: {
|
|
backupDigest: this.fingerprint(b),
|
|
backupExpiresAt: new Date(Date.now() + 10 * 60000),
|
|
},
|
|
});
|
|
return b;
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
|
);
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.zip"`,
|
|
);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.type('application/zip');
|
|
const archive = archiveBackup(b);
|
|
archive.on('error', () => res.destroy());
|
|
archive.pipe(res);
|
|
await archive.finalize().catch(() => res.destroy());
|
|
}
|
|
|
|
async upload(r: UserRequest, file?: Express.Multer.File) {
|
|
if (!file) throw new BadRequestException('请选择 ZIP 备份文件');
|
|
try {
|
|
const b = validateBackup(await this.uploadedData(file.path));
|
|
const result = await this.preview(r, b);
|
|
for (const [token, v] of this.uploads)
|
|
if (v.userId === r.userId) {
|
|
this.uploads.delete(token);
|
|
await unlink(v.path).catch(() => {});
|
|
}
|
|
const token = randomUUID();
|
|
this.uploads.set(token, {
|
|
sessionId: r.sessionId,
|
|
userId: r.userId,
|
|
path: file.path,
|
|
expires: Date.now() + 15 * 60000,
|
|
});
|
|
return { ...result, token };
|
|
} catch (e) {
|
|
await unlink(file.path).catch(() => {});
|
|
throw e;
|
|
}
|
|
}
|
|
async onModuleInit() {
|
|
await this.prune();
|
|
}
|
|
async importFile(r: UserRequest, raw: unknown) {
|
|
const { token } = z
|
|
.object({ confirmed: z.literal(true), token: z.string().uuid() })
|
|
.strict()
|
|
.parse(raw);
|
|
const v = this.uploads.get(token);
|
|
if (!v || v.userId !== r.userId || v.sessionId !== r.sessionId || v.expires < Date.now())
|
|
throw new BadRequestException('导入预览已失效,请重新选择备份');
|
|
this.uploads.delete(token);
|
|
try {
|
|
return await this.restore(r, { confirmed: true, backup: await this.uploadedData(v.path) });
|
|
} finally {
|
|
await unlink(v.path).catch(() => {});
|
|
}
|
|
}
|
|
private fingerprint(b: Backup) {
|
|
const { exportedAt, ...data } = structuredClone(b);
|
|
data.positions.sort((a, b) => a.id.localeCompare(b.id));
|
|
for (const p of data.positions) p.revisions.sort((a, b) => a.id.localeCompare(b.id));
|
|
data.links.sort((a, b) => (a.sourceId + a.targetId).localeCompare(b.sourceId + b.targetId));
|
|
data.rates.sort((a, b) =>
|
|
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
|
);
|
|
data.metalPrices.sort((a, b) =>
|
|
(a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date),
|
|
);
|
|
data.currencies.sort();
|
|
data.transfers.sort((a, b) => a.id.localeCompare(b.id));
|
|
data.schedules.sort((a, b) => a.id.localeCompare(b.id));
|
|
data.icons.sort((a, b) => a.id.localeCompare(b.id));
|
|
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
|
}
|
|
async clearStatus(r: UserRequest) {
|
|
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
|
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
|
|
}
|
|
async clear(r: UserRequest, raw: unknown) {
|
|
z.object({ confirmation: z.literal('确定清空') })
|
|
.strict()
|
|
.parse(raw);
|
|
return this.db.$transaction(
|
|
async (tx) => {
|
|
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
|
|
const s = await tx.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
|
if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now())
|
|
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
|
|
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
|
|
throw new ConflictException('数据已变化,请重新下载备份');
|
|
await tx.schedule.deleteMany({ where: { userId: r.userId } });
|
|
await tx.position.deleteMany({ where: { userId: r.userId } });
|
|
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
|
|
await tx.metalPrice.deleteMany({ where: { userId: r.userId } });
|
|
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
|
|
await tx.session.updateMany({
|
|
where: { userId: r.userId },
|
|
data: { backupDigest: null, backupExpiresAt: null, revealUntil: null },
|
|
});
|
|
return { ok: true };
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
|
);
|
|
}
|
|
async preview(r: UserRequest, raw: unknown) {
|
|
const b = validateBackup(raw),
|
|
existing = await this.data(r.userId);
|
|
for (const i of b.icons) await validateStoredIcon(i.image, i.hash);
|
|
this.conflicts(b, existing);
|
|
return {
|
|
positions: b.positions.length,
|
|
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
|
rates: b.rates.length,
|
|
icons: b.icons.length,
|
|
transfers: b.transfers.length,
|
|
schedules: b.schedules.length,
|
|
baseCurrency: b.baseCurrency,
|
|
currentBaseCurrency: existing.baseCurrency,
|
|
message:
|
|
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币和个人设置。确认后以事务导入,备份中的图标恢复为私有,不会自动发布到共享库。',
|
|
};
|
|
}
|
|
private conflicts(b: Backup, existing: Backup) {
|
|
const ids = new Set(
|
|
existing.positions.flatMap((p) => [p.id, p.importedFromId].filter(Boolean)),
|
|
);
|
|
if (b.positions.some((p) => ids.has(p.id) || ids.has(p.importedFromId || p.id)))
|
|
throw new ConflictException('包含已有或重复项目,请勿重复导入;首版只支持追加新项目');
|
|
for (const rate of b.rates) {
|
|
const e = existing.rates.find(
|
|
(r) =>
|
|
r.currency === rate.currency &&
|
|
r.baseCurrency === rate.baseCurrency &&
|
|
r.date === rate.date,
|
|
);
|
|
if (e && !new Decimal(e.rate).eq(rate.rate))
|
|
throw new ConflictException('已有同日汇率与备份冲突,未修改数据');
|
|
}
|
|
}
|
|
async restore(r: UserRequest, raw: unknown) {
|
|
const { backup } = z
|
|
.object({ confirmed: z.literal(true), backup: backupSchema })
|
|
.strict()
|
|
.parse(raw),
|
|
b = validateBackup(backup);
|
|
const iconData = new Map<string, Buffer>();
|
|
for (const i of b.icons) {
|
|
const data = await validateStoredIcon(i.image, i.hash);
|
|
iconData.set(i.id, data);
|
|
await persistIconFile(i.hash, data);
|
|
}
|
|
return this.db.$transaction(
|
|
async (tx) => {
|
|
const ps = await tx.position.findMany({
|
|
where: { userId: r.userId },
|
|
include: { revisions: true },
|
|
}),
|
|
rs = await tx.exchangeRate.findMany({ where: { userId: r.userId } });
|
|
const existing = {
|
|
positions: ps.map((p) => ({
|
|
...p,
|
|
revisions: p.revisions.map((v) => ({
|
|
...v,
|
|
amount: v.amount.toString(),
|
|
date: day(v.effectiveDate),
|
|
})),
|
|
})),
|
|
rates: rs.map((v) => ({ ...v, rate: v.rate.toString(), date: day(v.date) })),
|
|
} as unknown as Backup;
|
|
this.conflicts(b, existing);
|
|
const iconMapping = new Map<string, string>();
|
|
for (const i of b.icons) {
|
|
const row = await tx.icon.upsert({
|
|
where: { ownerId_hash_shared: { ownerId: r.userId, hash: i.hash, shared: false } },
|
|
create: {
|
|
ownerId: r.userId,
|
|
name: i.name,
|
|
hash: i.hash,
|
|
shared: false,
|
|
data: new Uint8Array(iconData.get(i.id)!),
|
|
},
|
|
update: {},
|
|
});
|
|
iconMapping.set(i.id, row.id);
|
|
}
|
|
const mapping = new Map<string, string>(),
|
|
revisionMapping = new Map<string, string>();
|
|
for (const p of b.positions) {
|
|
const row = await tx.position.create({
|
|
data: {
|
|
userId: r.userId,
|
|
importedFromId: p.importedFromId || p.id,
|
|
iconId: p.iconId ? iconMapping.get(p.iconId) : null,
|
|
name: p.name,
|
|
kind: p.kind,
|
|
side: p.side,
|
|
category: p.category,
|
|
groupName: p.groupName,
|
|
currency: p.currency,
|
|
notes: p.notes,
|
|
archived: p.archived,
|
|
hidden: p.hidden,
|
|
included: p.included,
|
|
lastRepaymentMonth: p.lastRepaymentMonth ?? null,
|
|
lastBookedDate: p.lastBookedDate ?? null,
|
|
metalType: p.metalType,
|
|
metalGrams: p.metalGrams,
|
|
metalCostPerGram: p.metalCostPerGram,
|
|
metalPurity: p.metalPurity,
|
|
autoValuation: p.autoValuation,
|
|
createdAt: new Date(p.createdAt),
|
|
updatedAt: new Date(p.updatedAt),
|
|
revisions: {
|
|
create: [...p.revisions]
|
|
.sort(
|
|
(a, b) =>
|
|
a.date.localeCompare(b.date) ||
|
|
(a.sequence || 0) - (b.sequence || 0) ||
|
|
a.createdAt.localeCompare(b.createdAt),
|
|
)
|
|
.map((v) => ({
|
|
amount: v.amount,
|
|
effectiveDate: toBusinessDate(v.date),
|
|
notes: v.notes,
|
|
reason: v.reason,
|
|
createdAt: new Date(v.createdAt),
|
|
updatedAt: new Date(v.updatedAt),
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
mapping.set(p.id, row.id);
|
|
const originals = [...p.revisions].sort(
|
|
(a, b) =>
|
|
a.date.localeCompare(b.date) ||
|
|
(a.sequence || 0) - (b.sequence || 0) ||
|
|
a.createdAt.localeCompare(b.createdAt),
|
|
);
|
|
const restored = await tx.revision.findMany({
|
|
where: { positionId: row.id },
|
|
orderBy: { sequence: 'asc' },
|
|
});
|
|
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
|
|
}
|
|
for (const q of b.metalPrices) {
|
|
const key = {
|
|
userId: r.userId,
|
|
metalType: q.metalType,
|
|
currency: q.currency,
|
|
date: new Date(q.date),
|
|
};
|
|
const existingQuote = await tx.metalPrice.findUnique({
|
|
where: { userId_metalType_currency_date: key },
|
|
});
|
|
if (existingQuote && !new Decimal(existingQuote.price.toString()).eq(q.price))
|
|
throw new ConflictException('已有同日贵金属价格与备份冲突');
|
|
if (!existingQuote)
|
|
await tx.metalPrice.create({
|
|
data: { ...key, price: q.price, source: q.source, quotedAt: new Date(q.quotedAt) },
|
|
});
|
|
}
|
|
for (const t of b.transfers)
|
|
await tx.transfer.create({
|
|
data: {
|
|
userId: r.userId,
|
|
importedFromId: t.importedFromId || t.id,
|
|
sourceId: mapping.get(t.sourceId)!,
|
|
targetId: mapping.get(t.targetId)!,
|
|
sourceRevisionId: revisionMapping.get(t.sourceRevisionId)!,
|
|
targetRevisionId: revisionMapping.get(t.targetRevisionId)!,
|
|
sourceCurrency: t.sourceCurrency,
|
|
targetCurrency: t.targetCurrency,
|
|
amount: t.amount,
|
|
received: t.received,
|
|
fee: t.fee,
|
|
operation: t.operation,
|
|
effectiveDate: toBusinessDate(t.date),
|
|
notes: t.notes,
|
|
createdAt: new Date(t.createdAt),
|
|
},
|
|
});
|
|
for (const plan of b.schedules) {
|
|
const { id, importedFromId, ...v } = plan;
|
|
await tx.schedule.create({
|
|
data: {
|
|
...v,
|
|
userId: r.userId,
|
|
importedFromId: importedFromId || id,
|
|
sourceId: mapping.get(plan.sourceId)!,
|
|
targetId: plan.targetId ? mapping.get(plan.targetId)! : null,
|
|
nextAt: new Date(plan.nextAt + ':00+08:00'),
|
|
},
|
|
});
|
|
}
|
|
for (const l of b.links)
|
|
await tx.positionLink.create({
|
|
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
|
});
|
|
for (const v of b.rates) {
|
|
const key = {
|
|
userId: r.userId,
|
|
currency: v.currency,
|
|
baseCurrency: v.baseCurrency,
|
|
date: new Date(v.date),
|
|
};
|
|
await tx.exchangeRate.upsert({
|
|
where: { userId_currency_baseCurrency_date: key },
|
|
create: { ...key, rate: v.rate, source: v.source },
|
|
update: {},
|
|
});
|
|
}
|
|
if (!ps.length && !rs.length)
|
|
await tx.user.update({
|
|
where: { id: r.userId },
|
|
data: {
|
|
baseCurrency: b.baseCurrency,
|
|
idleMinutes: b.preferences.idleMinutes,
|
|
hiddenMenus: b.preferences.hiddenMenus.join(','),
|
|
showNotes: b.preferences.showNotes,
|
|
accountGroupOrder: b.preferences.accountGroupOrder,
|
|
sessionHours: b.preferences.sessionHours,
|
|
requireHiddenPassword: b.preferences.requireHiddenPassword,
|
|
overviewCards: b.preferences.overviewCards,
|
|
includeIndependentAssets: b.preferences.includeIndependentAssets,
|
|
},
|
|
});
|
|
if (!ps.length && !rs.length)
|
|
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
|
|
return { ok: true, positions: b.positions.length };
|
|
},
|
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
|
);
|
|
}
|
|
}
|
|
|
|
@Controller('api/backup')
|
|
export class BackupController {
|
|
constructor(private service: BackupBusinessService) {}
|
|
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
|
return this.service.download(r, res);
|
|
}
|
|
@Post('upload')
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: diskStorage({
|
|
destination: tmpdir(),
|
|
filename: (_req, _file, cb) => cb(null, 'worthpath-import-' + randomUUID() + '.zip'),
|
|
}),
|
|
limits: { files: 1, fileSize: MAX_UPLOAD_BYTES, fields: 0 },
|
|
}),
|
|
)
|
|
async upload(@Req() r: UserRequest, @UploadedFile() file?: Express.Multer.File) {
|
|
return this.service.upload(r, file);
|
|
}
|
|
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() raw: unknown) {
|
|
return this.service.importFile(r, raw);
|
|
}
|
|
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
|
return this.service.clearStatus(r);
|
|
}
|
|
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
|
return this.service.clear(r, raw);
|
|
}
|
|
}
|