feat: add privacy controls, minute history and ZIP backups
This commit is contained in:
1 parent
ba0d5201c9
commit
2a650853ee
21 files changed
+1729
-226
No files matched your search
+35
-4
@@ -19,7 +19,7 @@ import { randomBytes, createHash } from 'node:crypto';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { Database } from './database';
|
||||
import { credentials } from './validation';
|
||||
export type UserRequest = Request & { userId: string };
|
||||
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
|
||||
const Public = () => SetMetadata('public', true);
|
||||
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
|
||||
@Injectable()
|
||||
@@ -55,7 +55,16 @@ export class AuthService {
|
||||
async user(token: unknown) {
|
||||
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) return null;
|
||||
const s = await this.db.session.findUnique({ where: { id: digest(token) } });
|
||||
return s && s.expiresAt > new Date() ? s.userId : null;
|
||||
if (!s || s.expiresAt <= new Date()) return null;
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: s.userId },
|
||||
select: { idleMinutes: true },
|
||||
});
|
||||
if (u.idleMinutes && Date.now() - +s.lastActivity >= u.idleMinutes * 60000) {
|
||||
await this.db.session.deleteMany({ where: { id: s.id } });
|
||||
throw new UnauthorizedException('长时间无操作,已自动退出登录');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
async logout(req: Request, res: Response) {
|
||||
if (typeof req.cookies?.wp_session === 'string')
|
||||
@@ -84,7 +93,9 @@ export class AuthGuard implements CanActivate {
|
||||
if (this.reflector.get<boolean>('public', ctx.getHandler())) return true;
|
||||
const id = await this.auth.user(req.cookies?.wp_session);
|
||||
if (!id) throw new UnauthorizedException('请先登录');
|
||||
req.userId = id;
|
||||
req.userId = id.userId;
|
||||
req.sessionId = id.id;
|
||||
req.revealed = !!id.revealUntil && +id.revealUntil > Date.now();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -129,9 +140,29 @@ export class AuthController {
|
||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||
return this.db.user.findUniqueOrThrow({
|
||||
where: { id: req.userId },
|
||||
select: { username: true, baseCurrency: true },
|
||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
});
|
||||
}
|
||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||
await this.db.session.update({
|
||||
where: { id: r.sessionId },
|
||||
data: { lastActivity: new Date() },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('auth/reveal') async reveal(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
this.auth.limit(r);
|
||||
const { password } = credentials.pick({ password: true }).parse(b);
|
||||
const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
|
||||
const revealUntil = new Date(Date.now() + 5 * 60000);
|
||||
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil } });
|
||||
return { revealUntil };
|
||||
}
|
||||
@Post('auth/lock') async lock(@Req() r: UserRequest) {
|
||||
await this.db.session.update({ where: { id: r.sessionId }, data: { revealUntil: null } });
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('auth/logout') async logout(
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
|
||||
+194
-29
@@ -7,15 +7,28 @@ import {
|
||||
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, open, readFile, 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, positionMeta, currency, revisionInput, rateInput } from './validation';
|
||||
import { day } from './calculation';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from './validation';
|
||||
import { day, businessTime } from './calculation';
|
||||
const timestamp = z.iso
|
||||
.datetime()
|
||||
.refine(
|
||||
@@ -40,31 +53,30 @@ const record = positionMeta
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(10000),
|
||||
.min(1),
|
||||
})
|
||||
.strict();
|
||||
const backupSchema = z
|
||||
.object({
|
||||
format: z.literal('worthpath'),
|
||||
version: z.literal(1),
|
||||
version: z.union([z.literal(1), z.literal(2)]),
|
||||
exportedAt: z.iso.datetime(),
|
||||
baseCurrency: currency,
|
||||
currencies: z.array(currency).max(10),
|
||||
positions: z.array(record).max(1000),
|
||||
links: z
|
||||
.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict())
|
||||
.max(20000),
|
||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })).max(20000),
|
||||
preferences: z
|
||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
||||
.strict()
|
||||
.optional(),
|
||||
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();
|
||||
type Backup = z.infer<typeof backupSchema>;
|
||||
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');
|
||||
if (b.positions.reduce((n, p) => n + p.revisions.length, 0) > 20000)
|
||||
throw new BadRequestException('单次备份最多 20000 条历史');
|
||||
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
||||
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
|
||||
const revisionIds = new Set<string>();
|
||||
@@ -77,6 +89,7 @@ export function validateBackup(raw: unknown) {
|
||||
currency: p.currency,
|
||||
notes: p.notes,
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
amount: '0',
|
||||
date: p.revisions[0].date,
|
||||
});
|
||||
@@ -110,19 +123,60 @@ export function validateBackup(raw: unknown) {
|
||||
return b;
|
||||
}
|
||||
@Controller('api/backup')
|
||||
export class BackupController {
|
||||
export class BackupController 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) {
|
||||
const handle = await open(path, 'r');
|
||||
const prefix = Buffer.alloc(2);
|
||||
try {
|
||||
await handle.read(prefix, 0, 2, 0);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return prefix.toString() === 'PK'
|
||||
? readBackupZip(path)
|
||||
: JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
constructor(private db: Database) {}
|
||||
private async data(userId: string): Promise<Backup> {
|
||||
const [user, ps, rates] = await this.db.$transaction([
|
||||
this.db.user.findUniqueOrThrow({
|
||||
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 },
|
||||
select: { baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
}),
|
||||
this.db.position.findMany({
|
||||
client.position.findMany({
|
||||
where: { userId },
|
||||
include: { revisions: true, outgoing: true },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
},
|
||||
}),
|
||||
this.db.exchangeRate.findMany({ where: { userId } }),
|
||||
client.exchangeRate.findMany({ where: { userId } }),
|
||||
]);
|
||||
const positions = ps.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -134,13 +188,14 @@ export class BackupController {
|
||||
currency: p.currency,
|
||||
notes: p.notes,
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
revisions: p.revisions.map((r) => ({
|
||||
id: r.id,
|
||||
sequence: r.sequence,
|
||||
amount: r.amount.toString(),
|
||||
date: day(r.effectiveDate),
|
||||
date: businessTime(r.effectiveDate),
|
||||
notes: r.notes,
|
||||
reason: r.reason,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
@@ -149,9 +204,10 @@ export class BackupController {
|
||||
}));
|
||||
return backupSchema.parse({
|
||||
format: 'worthpath',
|
||||
version: 1,
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
baseCurrency: user.baseCurrency,
|
||||
preferences: { showSidebar: user.showSidebar, idleMinutes: user.idleMinutes },
|
||||
currencies: [
|
||||
...new Set([
|
||||
user.baseCurrency,
|
||||
@@ -173,13 +229,118 @@ export class BackupController {
|
||||
});
|
||||
}
|
||||
@Get() async download(@Req() r: UserRequest, @Res() res: Response) {
|
||||
const b = await this.data(r.userId);
|
||||
const b = await this.db.$transaction(
|
||||
async (tx) => {
|
||||
const b = await this.data(r.userId, tx);
|
||||
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 },
|
||||
);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.json"`,
|
||||
`attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.zip"`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.type('application/json').send(JSON.stringify(b, null, 2));
|
||||
res.type('application/zip');
|
||||
const archive = archiveBackup(b);
|
||||
archive.on('error', () => res.destroy());
|
||||
archive.pipe(res);
|
||||
await archive.finalize().catch(() => res.destroy());
|
||||
}
|
||||
@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) {
|
||||
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();
|
||||
}
|
||||
@Post('import-file') async importFile(@Req() r: UserRequest, @Body() 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.currencies.sort();
|
||||
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
|
||||
}
|
||||
@Get('clear-status') async clearStatus(@Req() r: UserRequest) {
|
||||
const s = await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
||||
return { ready: !!s.backupDigest && !!s.backupExpiresAt && +s.backupExpiresAt > Date.now() };
|
||||
}
|
||||
@Post('clear') async clear(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
z.object({ confirmation: z.literal('确定清空') })
|
||||
.strict()
|
||||
.parse(raw);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
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.position.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 },
|
||||
);
|
||||
}
|
||||
@Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) {
|
||||
const b = validateBackup(raw),
|
||||
@@ -192,7 +353,7 @@ export class BackupController {
|
||||
baseCurrency: b.baseCurrency,
|
||||
currentBaseCurrency: existing.baseCurrency,
|
||||
message:
|
||||
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币。确认后以事务导入。',
|
||||
'只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币和个人设置。确认后以事务导入。',
|
||||
};
|
||||
}
|
||||
private conflicts(b: Backup, existing: Backup) {
|
||||
@@ -250,6 +411,7 @@ export class BackupController {
|
||||
currency: p.currency,
|
||||
notes: p.notes,
|
||||
archived: p.archived,
|
||||
hidden: p.hidden,
|
||||
createdAt: new Date(p.createdAt),
|
||||
updatedAt: new Date(p.updatedAt),
|
||||
revisions: {
|
||||
@@ -262,7 +424,7 @@ export class BackupController {
|
||||
)
|
||||
.map((v) => ({
|
||||
amount: v.amount,
|
||||
effectiveDate: new Date(v.date),
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: v.reason,
|
||||
createdAt: new Date(v.createdAt),
|
||||
@@ -291,10 +453,13 @@ export class BackupController {
|
||||
});
|
||||
}
|
||||
if (!ps.length && !rs.length)
|
||||
await tx.user.update({ where: { id: r.userId }, data: { baseCurrency: b.baseCurrency } });
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { baseCurrency: b.baseCurrency, ...b.preferences },
|
||||
});
|
||||
return { ok: true, positions: b.positions.length };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 30000 },
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export type Rate = {
|
||||
source: string;
|
||||
};
|
||||
export const day = (d: Date) => d.toISOString().slice(0, 10);
|
||||
export const businessTime = (d: Date) => new Date(+d + 8 * 3600000).toISOString().slice(0, 16);
|
||||
export const businessDay = (d: Date) => businessTime(d).slice(0, 10);
|
||||
export function compareRevisions(a: Holding['revisions'][number], b: Holding['revisions'][number]) {
|
||||
return +a.effectiveDate - +b.effectiveDate || (a.sequence || 0) - (b.sequence || 0);
|
||||
}
|
||||
@@ -39,7 +41,8 @@ export function history(p: Holding) {
|
||||
name: p.name,
|
||||
kind: p.kind,
|
||||
currency: p.currency,
|
||||
date: day(r.effectiveDate),
|
||||
date: businessDay(r.effectiveDate),
|
||||
time: businessTime(r.effectiveDate),
|
||||
before: before.toFixed(),
|
||||
after: after.toFixed(),
|
||||
delta: after.minus(before).toFixed(),
|
||||
@@ -63,7 +66,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
|
||||
const missing = new Set<string>();
|
||||
const items = positions.map((p) => {
|
||||
const rev = p.revisions
|
||||
.filter((r) => day(r.effectiveDate) <= date)
|
||||
.filter((r) => businessDay(r.effectiveDate) <= date)
|
||||
.sort((a, b) => compareRevisions(b, a))[0],
|
||||
amount = new Decimal(rev?.amount.toString() || '0'),
|
||||
fx = rateAt(rates, p.currency, base, date);
|
||||
@@ -98,7 +101,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
|
||||
export function overview(positions: Holding[], rates: Rate[], base: string, date: string) {
|
||||
const dates = [
|
||||
...new Set([
|
||||
...positions.flatMap((p) => p.revisions.map((r) => day(r.effectiveDate))),
|
||||
...positions.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate))),
|
||||
...rates.filter((r) => r.baseCurrency === base).map((r) => day(r.date)),
|
||||
date,
|
||||
]),
|
||||
@@ -135,7 +138,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
|
||||
trend,
|
||||
recent: positions
|
||||
.flatMap(history)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.sequence - a.sequence)
|
||||
.sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence)
|
||||
.slice(0, 20),
|
||||
};
|
||||
}
|
||||
+22
-15
@@ -13,7 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { positionInput, positionMeta, revisionInput, today } from './validation';
|
||||
import { positionInput, positionMeta, revisionInput, today, toBusinessDate } from './validation';
|
||||
import { history, overview } from './calculation';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -24,9 +24,9 @@ export class PortfolioController {
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
) {}
|
||||
private async own(userId: string, id: string) {
|
||||
private async own(userId: string, id: string, revealed = false) {
|
||||
const p = await this.db.position.findFirst({
|
||||
where: { id, userId },
|
||||
where: { id, userId, ...(revealed ? {} : { hidden: false }) },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
@@ -37,7 +37,7 @@ export class PortfolioController {
|
||||
}
|
||||
@Get('positions') async list(@Req() r: UserRequest) {
|
||||
const rows = await this.db.position.findMany({
|
||||
where: { userId: r.userId },
|
||||
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
@@ -52,7 +52,7 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
const p = await this.own(r.userId, id);
|
||||
const p = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, userId: undefined, history: history(p) };
|
||||
}
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
@@ -63,7 +63,12 @@ export class PortfolioController {
|
||||
...meta,
|
||||
userId: r.userId,
|
||||
revisions: {
|
||||
create: { amount, effectiveDate: new Date(date), notes: v.notes, reason: 'initial' },
|
||||
create: {
|
||||
amount,
|
||||
effectiveDate: toBusinessDate(date),
|
||||
notes: v.notes,
|
||||
reason: 'initial',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -77,7 +82,7 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = positionMeta.parse(b),
|
||||
p = await this.own(r.userId, id);
|
||||
p = await this.own(r.userId, id, r.revealed);
|
||||
if (
|
||||
p.kind === 'account' &&
|
||||
['credit_card', 'loan'].includes(v.category) &&
|
||||
@@ -95,13 +100,15 @@ export class PortfolioController {
|
||||
const v = revisionInput.parse(b);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const p = await tx.position.findFirst({ where: { id, userId: r.userId } });
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
if (v.reason === 'repayment') {
|
||||
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
||||
const prior = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, effectiveDate: { lte: new Date(v.date) } },
|
||||
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
|
||||
@@ -111,7 +118,7 @@ export class PortfolioController {
|
||||
data: {
|
||||
positionId: p.id,
|
||||
amount: v.amount,
|
||||
effectiveDate: new Date(v.date),
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: v.reason,
|
||||
},
|
||||
@@ -127,7 +134,7 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b),
|
||||
p = await this.own(r.userId, id);
|
||||
p = await this.own(r.userId, id, r.revealed);
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
if (!p.revisions.some((x) => x.id === revisionId))
|
||||
throw new NotFoundException('历史记录不存在');
|
||||
@@ -135,7 +142,7 @@ export class PortfolioController {
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
effectiveDate: new Date(v.date),
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: 'correction',
|
||||
},
|
||||
@@ -156,7 +163,7 @@ export class PortfolioController {
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const source = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, kind: 'debt' },
|
||||
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!source) throw new NotFoundException('债务不存在');
|
||||
const count = await tx.position.count({
|
||||
@@ -180,11 +187,11 @@ export class PortfolioController {
|
||||
select: { baseCurrency: true },
|
||||
}),
|
||||
this.db.position.findMany({
|
||||
where: { userId: r.userId },
|
||||
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: { revisions: true },
|
||||
}),
|
||||
this.db.exchangeRate.findMany({ where: { userId: r.userId } }),
|
||||
]);
|
||||
return overview(positions, rates, user.baseCurrency, today());
|
||||
return { ...overview(positions, rates, user.baseCurrency, today()), revealed: r.revealed };
|
||||
}
|
||||
}
|
||||
+49
-35
@@ -4,7 +4,6 @@ import {
|
||||
Get,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Body,
|
||||
OnModuleInit,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { currency, rateInput, date, rateValue, today } from './validation';
|
||||
import { currency, date, rateValue, today } from './validation';
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||
@@ -115,28 +114,44 @@ export class RatesService implements OnModuleInit, OnModuleDestroy {
|
||||
source: 'frankfurter',
|
||||
};
|
||||
});
|
||||
await this.db.$transaction(async (tx) => {
|
||||
for (const v of data) {
|
||||
const { rate, source, ...key } = v;
|
||||
const existing = await tx.exchangeRate.findUnique({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
await this.db.$transaction(
|
||||
async (tx) => {
|
||||
// A clear or currency change during the network request must not recreate stale rates.
|
||||
const currentUser = await tx.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const currentPositions = await tx.position.findMany({
|
||||
where: { userId },
|
||||
select: { currency: true },
|
||||
distinct: ['currency'],
|
||||
});
|
||||
if (existing?.source === 'manual') continue;
|
||||
await tx.exchangeRate.upsert({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
create: v,
|
||||
update: { rate, source },
|
||||
});
|
||||
}
|
||||
});
|
||||
const message = '已保存最新可用日汇率;休市日可能沿用上一工作日。同日手动汇率已保留。';
|
||||
if (
|
||||
currentUser.baseCurrency !== u.baseCurrency ||
|
||||
JSON.stringify(currentPositions.map((p) => p.currency).sort()) !==
|
||||
JSON.stringify(ps.map((p) => p.currency).sort())
|
||||
)
|
||||
return;
|
||||
for (const v of data) {
|
||||
const { rate, source, ...key } = v;
|
||||
const existing = await tx.exchangeRate.findUnique({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
});
|
||||
if (existing?.source === 'manual') continue;
|
||||
await tx.exchangeRate.upsert({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
create: v,
|
||||
update: { rate, source },
|
||||
});
|
||||
}
|
||||
},
|
||||
{ isolationLevel: 'Serializable' },
|
||||
);
|
||||
const message = '已保存最新可用日汇率;休市日可能沿用上一工作日。同日历史导入汇率已保留。';
|
||||
this.outcomes.set(userId, { state: 'ok', attemptedAt: new Date().toISOString(), message });
|
||||
return { message };
|
||||
} catch {
|
||||
this.outcomes.set(userId, {
|
||||
state: 'error',
|
||||
attemptedAt: new Date().toISOString(),
|
||||
message: '自动汇率更新失败,原币和已有汇率已保留,请重试或手动录入',
|
||||
message: '自动汇率更新失败,原币和已有汇率已保留,请稍后重试',
|
||||
});
|
||||
throw new BadGatewayException('汇率更新失败,原币金额和已有汇率已保留;可稍后重试或手动录入');
|
||||
} finally {
|
||||
@@ -153,10 +168,16 @@ export class SettingsController {
|
||||
@Get('settings') async settings(@Req() r: UserRequest) {
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { username: true, baseCurrency: true },
|
||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.lastActivity,
|
||||
revealed: r.revealed,
|
||||
revealUntil: r.revealed
|
||||
? (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } })).revealUntil
|
||||
: null,
|
||||
fxStatus: this.fx.status(r.userId),
|
||||
rates: await this.db.exchangeRate.findMany({
|
||||
where: { userId: r.userId },
|
||||
@@ -166,26 +187,19 @@ export class SettingsController {
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const { baseCurrency } = z.object({ baseCurrency: currency }).strict().parse(b);
|
||||
await this.db.user.update({ where: { id: r.userId }, data: { baseCurrency } });
|
||||
const data = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
showSidebar: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Object.keys(v).length > 0)
|
||||
.parse(b);
|
||||
await this.db.user.update({ where: { id: r.userId }, data });
|
||||
this.fx.invalidate(r.userId);
|
||||
return { ok: true };
|
||||
}
|
||||
@Put('rates') async manual(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const v = rateInput.parse(b),
|
||||
key = {
|
||||
userId: r.userId,
|
||||
currency: v.currency,
|
||||
baseCurrency: v.baseCurrency,
|
||||
date: new Date(v.date),
|
||||
};
|
||||
await this.db.exchangeRate.upsert({
|
||||
where: { userId_currency_baseCurrency_date: key },
|
||||
create: { ...key, rate: v.rate, source: 'manual' },
|
||||
update: { rate: v.rate, source: 'manual' },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
@Post('rates/refresh') async refresh(@Req() r: UserRequest) {
|
||||
return this.fx.refresh(r.userId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@ export const date = z
|
||||
Number.isFinite(+d) && d.toISOString().slice(0, 10) === s && s >= '1900-01-01' && s <= today()
|
||||
);
|
||||
}, '日期无效或在未来');
|
||||
export const businessDate = z.string().refine((s) => {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return date.safeParse(s).success;
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(s)) return false;
|
||||
const d = new Date(s + ':00+08:00');
|
||||
return (
|
||||
Number.isFinite(+d) &&
|
||||
new Date(+d + 8 * 3600000).toISOString().slice(0, 16) === s &&
|
||||
s >= '1900-01-01' &&
|
||||
+d <= Date.now()
|
||||
);
|
||||
}, '业务时间无效或在未来(北京时间)');
|
||||
export const toBusinessDate = (s: string) =>
|
||||
new Date((s.length === 10 ? s + 'T00:00' : s) + ':00+08:00');
|
||||
export const amount = z
|
||||
.string()
|
||||
.regex(/^(0|[1-9]\d{0,15})(\.\d{1,8})?$/, '金额须为非负十进制字符串,最多 16 位整数和 8 位小数');
|
||||
@@ -40,7 +53,7 @@ export const notes = z.string().max(2000).default('');
|
||||
export const revisionInput = z
|
||||
.object({
|
||||
amount,
|
||||
date,
|
||||
date: businessDate,
|
||||
notes,
|
||||
reason: z
|
||||
.enum(['initial', 'balance', 'valuation', 'repayment', 'correction'])
|
||||
@@ -53,6 +66,7 @@ export const positionMeta = z
|
||||
category: z.string().trim().min(1).max(40),
|
||||
notes,
|
||||
archived: z.boolean().default(false),
|
||||
hidden: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
export const positionInput = positionMeta
|
||||
@@ -61,7 +75,7 @@ export const positionInput = positionMeta
|
||||
side: z.enum(['asset', 'liability']),
|
||||
currency,
|
||||
amount,
|
||||
date,
|
||||
date: businessDate,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((p, c) => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ZipArchive } from 'archiver';
|
||||
import * as yauzl from 'yauzl';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import type { Backup } from './backup';
|
||||
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
||||
const files = [
|
||||
'settings.json',
|
||||
'currencies.json',
|
||||
'accounts.json',
|
||||
'assets.json',
|
||||
'debts.json',
|
||||
'history.json',
|
||||
'links.json',
|
||||
'rates.json',
|
||||
] as const;
|
||||
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
||||
export function packBackup(b: Backup) {
|
||||
const metadata = b.positions.map(({ revisions, ...p }) => p);
|
||||
const data: Record<string, unknown> = {
|
||||
'settings.json': { baseCurrency: b.baseCurrency, preferences: b.preferences },
|
||||
'currencies.json': b.currencies,
|
||||
'accounts.json': metadata.filter((p) => p.kind === 'account'),
|
||||
'assets.json': metadata.filter((p) => p.kind === 'asset'),
|
||||
'debts.json': metadata.filter((p) => p.kind === 'debt'),
|
||||
'history.json': b.positions.flatMap((p) =>
|
||||
p.revisions.map((r) => ({ ...r, positionId: p.id })),
|
||||
),
|
||||
'links.json': b.links,
|
||||
'rates.json': b.rates,
|
||||
};
|
||||
const contents = Object.fromEntries(
|
||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||
);
|
||||
contents['manifest.json'] = JSON.stringify(
|
||||
{
|
||||
format: 'worthpath',
|
||||
version: 3,
|
||||
exportedAt: b.exportedAt,
|
||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return contents;
|
||||
}
|
||||
export function archiveBackup(b: Backup) {
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||
for (const [name, contents] of Object.entries(packBackup(b))) archive.append(contents, { name });
|
||||
return archive;
|
||||
}
|
||||
export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
const zip = await new Promise<yauzl.ZipFile>((resolve, reject) => {
|
||||
const callback = (err: Error | null, value?: yauzl.ZipFile) =>
|
||||
err || !value ? reject(err || Error()) : resolve(value);
|
||||
const options = { lazyEntries: true, validateEntrySizes: true, strictFileNames: true };
|
||||
if (typeof input === 'string') yauzl.open(input, options, callback);
|
||||
else yauzl.fromBuffer(input, options, callback);
|
||||
}).catch(() => {
|
||||
throw new BadRequestException('ZIP 文件无效或已损坏');
|
||||
});
|
||||
try {
|
||||
const contents = await new Promise<Map<string, Buffer>>((resolve, reject) => {
|
||||
const result = new Map<string, Buffer>();
|
||||
let expanded = 0;
|
||||
zip.on('error', reject);
|
||||
zip.on('end', () => resolve(result));
|
||||
zip.on('entry', (entry: yauzl.Entry) => {
|
||||
if (
|
||||
![...files, 'manifest.json'].includes(entry.fileName as any) ||
|
||||
result.has(entry.fileName) ||
|
||||
entry.isEncrypted()
|
||||
) {
|
||||
reject(Error());
|
||||
zip.close();
|
||||
return;
|
||||
}
|
||||
expanded += entry.uncompressedSize;
|
||||
if (expanded > MAX_EXPANDED_BYTES) {
|
||||
reject(Error('size'));
|
||||
zip.close();
|
||||
return;
|
||||
}
|
||||
zip.openReadStream(entry, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
reject(err || Error());
|
||||
zip.close();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > entry.uncompressedSize) {
|
||||
stream.destroy(Error());
|
||||
} else chunks.push(chunk);
|
||||
});
|
||||
stream.on('end', () => {
|
||||
result.set(entry.fileName, Buffer.concat(chunks));
|
||||
zip.readEntry();
|
||||
});
|
||||
});
|
||||
});
|
||||
zip.readEntry();
|
||||
});
|
||||
if (contents.size !== files.length + 1) throw Error();
|
||||
const parse = (name: string) =>
|
||||
JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(contents.get(name)!));
|
||||
const manifest = z
|
||||
.object({
|
||||
format: z.literal('worthpath'),
|
||||
version: z.literal(3),
|
||||
exportedAt: z.iso.datetime(),
|
||||
files: z
|
||||
.array(
|
||||
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
||||
)
|
||||
.length(files.length),
|
||||
})
|
||||
.strict()
|
||||
.parse(parse('manifest.json'));
|
||||
if (new Set(manifest.files.map((f) => f.name)).size !== files.length) throw Error();
|
||||
for (const f of manifest.files) if (sha(contents.get(f.name)!) !== f.sha256) throw Error();
|
||||
const settings = z
|
||||
.object({
|
||||
baseCurrency: z.string(),
|
||||
preferences: z
|
||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(parse('settings.json'));
|
||||
const positions = (['accounts.json', 'assets.json', 'debts.json'] as const).flatMap(
|
||||
(name, index) => {
|
||||
const rows = z.array(z.record(z.string(), z.unknown())).parse(parse(name));
|
||||
if (rows.some((p) => p.kind !== ['account', 'asset', 'debt'][index] || 'revisions' in p))
|
||||
throw Error();
|
||||
return rows;
|
||||
},
|
||||
);
|
||||
const histories = z.array(z.record(z.string(), z.unknown())).parse(parse('history.json'));
|
||||
const ids = new Set(positions.map((p) => p.id));
|
||||
const grouped = new Map<unknown, unknown[]>();
|
||||
for (const { positionId, ...r } of histories) {
|
||||
if (!ids.has(positionId)) throw Error();
|
||||
const list = grouped.get(positionId) || [];
|
||||
list.push(r);
|
||||
grouped.set(positionId, list);
|
||||
}
|
||||
return {
|
||||
format: 'worthpath',
|
||||
version: 2,
|
||||
exportedAt: manifest.exportedAt,
|
||||
...settings,
|
||||
currencies: parse('currencies.json'),
|
||||
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
||||
links: parse('links.json'),
|
||||
rates: parse('rates.json'),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
'备份 ZIP 无效:请检查 JSON 文件、完整性及文件大小(上传 512 MB,解压总计 1 GB;不限制记录条数)',
|
||||
);
|
||||
} finally {
|
||||
zip.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user