diff --git a/README.md b/README.md index 631ddc8..34a2abf 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,14 @@ pnpm db:status pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件 ``` -当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、业务日期余额历史与更正、本位币、每日参考汇率与手动汇率、净资产趋势和变化归因、用户 JSON 备份与事务追加恢复。 +当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、侧栏及无操作退出设置、安全清空本账号数据。 金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额,已有错误记录可单独更正。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 -汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以手动重试。自动更新不会覆盖同日手动汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额,可手动补录。 +汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。 备份 v1 最多 8 MB / 1000 项目 / 20000 条历史,导入先预览、再明确确认。首版只追加新项目并重建关系,不覆盖已有项目;按项目 ID 和导入来源识别重复;同日汇率冲突会拒绝整次导入。已有本位币保留,空空间恢复备份本位币。文件包含财务数据,应由用户妥善保存。 设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。 + +备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 diff --git a/apps/api/package.json b/apps/api/package.json index 3c43b15..ee52984 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,25 +5,28 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test test/calculation.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", - "test:integration": "tsx --test test/integration.test.ts" + "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts" }, "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@prisma/client": "6.19.0", + "archiver": "^8.0.0", "bcryptjs": "^3.0.0", "cookie-parser": "^1.4.7", "decimal.js": "^10.6.0", "dotenv": "^17.2.0", "express": "5.1.0", "helmet": "^8.1.0", + "multer": "^2.4.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", + "yauzl": "^3.4.0", "zod": "^4.1.0" }, "devDependencies": { @@ -33,6 +36,9 @@ "mysql2": "^3.15.0", "prisma": "6.19.0", "tsx": "^4.20.0", - "typescript": "^5.9.0" + "typescript": "^5.9.0", + "@types/archiver": "^8.0.0", + "@types/yauzl": "^3.4.0", + "@types/multer": "^2.3.0" } } diff --git a/apps/api/prisma/migrations/202610010004_privacy_time_settings/migration.sql b/apps/api/prisma/migrations/202610010004_privacy_time_settings/migration.sql new file mode 100644 index 0000000..e6d3808 --- /dev/null +++ b/apps/api/prisma/migrations/202610010004_privacy_time_settings/migration.sql @@ -0,0 +1,10 @@ +ALTER TABLE `User` ADD COLUMN `showSidebar` BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN `idleMinutes` INTEGER NOT NULL DEFAULT 30; +ALTER TABLE `Session` ADD COLUMN `lastActivity` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + ADD COLUMN `revealUntil` DATETIME(3) NULL, + ADD COLUMN `backupDigest` CHAR(64) NULL, + ADD COLUMN `backupExpiresAt` DATETIME(3) NULL; +ALTER TABLE `Position` ADD COLUMN `hidden` BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE `Revision` MODIFY COLUMN `effectiveDate` DATETIME(3) NOT NULL; +-- Existing date-only entries represent midnight in Asia/Hong_Kong. +UPDATE `Revision` SET `effectiveDate` = DATE_SUB(`effectiveDate`, INTERVAL 8 HOUR); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6e24088..53c9019 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -10,6 +10,8 @@ model User { username String @unique @db.VarChar(64) passwordHash String @db.VarChar(255) baseCurrency String @default("CNY") @db.Char(3) + showSidebar Boolean @default(true) + idleMinutes Int @default(30) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt positions Position[] @@ -21,6 +23,10 @@ model Session { userId String @db.Char(36) user User @relation(fields:[userId],references:[id],onDelete:Cascade) expiresAt DateTime + lastActivity DateTime @default(now()) + revealUntil DateTime? + backupDigest String? @db.Char(64) + backupExpiresAt DateTime? @@index([userId]) } model Position { @@ -35,6 +41,7 @@ model Position { currency String @db.Char(3) notes String @db.Text archived Boolean @default(false) + hidden Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt revisions Revision[] @@ -49,7 +56,7 @@ model Revision { positionId String @db.Char(36) position Position @relation(fields:[positionId],references:[id],onDelete:Cascade) amount Decimal @db.Decimal(24,8) - effectiveDate DateTime @db.Date + effectiveDate DateTime @db.DateTime(3) notes String @db.Text reason String @db.VarChar(20) createdAt DateTime @default(now()) diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 2e34045..9680227 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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('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, diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 71e0860..9dcdfea 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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; +export type Backup = z.infer; 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(); @@ -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 { - const [user, ps, rates] = await this.db.$transaction([ - this.db.user.findUniqueOrThrow({ + private async data( + userId: string, + client: Database | Prisma.TransactionClient = this.db, + ): Promise { + 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 }, ); } } diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index c3d5192..a5b2281 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -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(); 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), }; } diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index d0d28a9..488f7a2 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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 }; } } diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index c6d3908..50e8eb9 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -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); } diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 2b604e2..1c0175c 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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) => { diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts new file mode 100644 index 0000000..5cc869f --- /dev/null +++ b/apps/api/src/zip.ts @@ -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 = { + '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 { + const zip = await new Promise((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>((resolve, reject) => { + const result = new Map(); + 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(); + 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(); + } +} diff --git a/apps/api/test/calculation.test.ts b/apps/api/test/calculation.test.ts index da3e9d8..4f7f255 100644 --- a/apps/api/test/calculation.test.ts +++ b/apps/api/test/calculation.test.ts @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { history, overview, totals, type Holding, type Rate } from '../src/calculation'; -import { positionInput, date, amount } from '../src/validation'; +import { positionInput, date, amount, businessDate, toBusinessDate } from '../src/validation'; import { validateBackup } from '../src/backup'; import { RatesService } from '../src/rates'; import { Database } from '../src/database'; @@ -34,6 +34,15 @@ test('decimal totals and liability sign', () => { b.revisions[0].amount = '0.2'; assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90'); }); +test('minute history uses Hong Kong day boundaries and rejects invalid local times', () => { + const a = p(); + a.revisions = [{ ...rev('8', '2026-09-01'), effectiveDate: toBusinessDate('2026-09-02T00:01') }]; + assert.equal(history(a)[0].time, '2026-09-02T00:01'); + assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '0.00'); + assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '8.00'); + assert.equal(businessDate.safeParse('2026-02-30T09:17').success, false); + assert.equal(businessDate.safeParse('2026-09-01T25:17').success, false); +}); test('missing FX explicitly incomplete', () => { const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01'); assert.equal(v.complete, false); @@ -118,6 +127,8 @@ test('public FX uses a fixed request, preserves decimal tokens, manual rates and position: { findMany: async () => [{ currency: 'USD' }] }, $transaction: async (fn: any) => fn({ + user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) }, + position: { findMany: async () => [{ currency: 'USD' }] }, exchangeRate: { findUnique: async () => (manual ? { source: 'manual' } : null), upsert: async (v: any) => writes.push(v.create), diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index 70a10e3..c5c1814 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { randomBytes, randomUUID } from 'node:crypto'; import { PrismaClient } from '@prisma/client'; import { today } from '../src/validation'; +import { readBackupZip } from '../src/zip'; const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api', origin = process.env.WEB_ORIGIN!; test('real MySQL: authentication, isolation, history, backup and atomic failures', async () => { @@ -21,7 +22,9 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures }); return { status: res.status, - data: await res.json(), + data: res.headers.get('content-type')?.includes('application/zip') + ? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any) + : await res.json(), cookie: res.headers.get('set-cookie')?.split(';')[0] || '', }; } @@ -137,19 +140,22 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures a.cookie, ) ).status, - 200, - ); - assert.equal( - ( - await call( - '/rates', - 'PUT', - { currency: 'USD', baseCurrency: 'CNY', rate: '7', date: today() }, - a.cookie, - ) - ).status, - 200, + 404, ); + const owner = await db.user.findUniqueOrThrow({ where: { username: a.username } }); + for (const businessDay of ['2026-09-01', today()]) { + const key = { + userId: owner.id, + currency: 'USD', + baseCurrency: 'CNY', + date: new Date(businessDay), + }; + await db.exchangeRate.upsert({ + where: { userId_currency_baseCurrency_date: key }, + create: { ...key, rate: '7', source: 'manual' }, + update: { rate: '7', source: 'manual' }, + }); + } let o = (await call('/overview', 'GET', undefined, a.cookie)).data; assert.equal(o.complete, true); assert.equal(o.net, '550.10'); diff --git a/apps/api/test/privacy.test.ts b/apps/api/test/privacy.test.ts new file mode 100644 index 0000000..4855860 --- /dev/null +++ b/apps/api/test/privacy.test.ts @@ -0,0 +1,272 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { readBackupZip } from '../src/zip'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; +test('privacy, minute history, backup-gated clear and idle sessions remain user scoped', async () => { + const db = new PrismaClient(), + names: string[] = []; + async function call(path: string, method = 'GET', data?: unknown, cookie = '') { + const res = await fetch(base + path, { + method, + headers: { + Origin: process.env.WEB_ORIGIN!, + Cookie: cookie, + ...(data ? { 'Content-Type': 'application/json' } : {}), + }, + body: data ? JSON.stringify(data) : undefined, + }); + return { + status: res.status, + data: res.headers.get('content-type')?.includes('application/zip') + ? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any) + : await res.json(), + cookie: res.headers.get('set-cookie')?.split(';')[0] || '', + }; + } + async function account() { + const username = 'wp_privacy_' + randomUUID(), + password = randomBytes(18).toString('hex'); + names.push(username); + const r = await call('/auth/register', 'POST', { username, password }); + assert.equal(r.status, 201); + const u = await db.user.findUniqueOrThrow({ where: { username } }); + return { ...r, username, password, id: u.id }; + } + const sessionId = (cookie: string) => + createHash('sha256').update(cookie.split('=')[1]).digest('hex'); + try { + const a = await account(), + b = await account(); + async function position(cookie: string, hidden: boolean, amount: string) { + const r = await call( + '/positions', + 'POST', + { + kind: 'account', + side: 'asset', + category: 'bank', + name: 'Temporary privacy acceptance', + currency: 'CNY', + amount, + date: '2026-09-01T09:17', + hidden, + }, + cookie, + ); + assert.equal(r.status, 201); + return r.data.id as string; + } + const visible = await position(a.cookie, false, '20'), + hidden = await position(a.cookie, true, '80'); + await position(b.cookie, false, '7'); + assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00'); + assert.equal((await call('/positions', 'GET', undefined, a.cookie)).data.length, 1); + assert.equal((await call('/positions/' + hidden, 'GET', undefined, a.cookie)).status, 404); + assert.equal( + ( + await call( + '/positions/' + hidden + '/revisions', + 'POST', + { amount: '90', date: '2026-09-01T09:18' }, + a.cookie, + ) + ).status, + 404, + ); + assert.equal( + (await call('/auth/reveal', 'POST', { password: b.password }, a.cookie)).status, + 403, + ); + assert.equal( + (await call('/auth/reveal', 'POST', { password: a.password, userId: b.id }, a.cookie)).status, + 400, + ); + assert.equal( + (await call('/auth/reveal', 'POST', { password: a.password }, a.cookie)).status, + 201, + ); + assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '100.00'); + assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '7.00'); + const otherSession = await call('/auth/login', 'POST', { + username: a.username, + password: a.password, + }); + assert.equal( + (await call('/overview', 'GET', undefined, otherSession.cookie)).data.net, + '20.00', + ); + assert.equal( + ( + await call( + '/positions/' + hidden + '/revisions', + 'POST', + { amount: '95', date: '2026-09-01T09:18' }, + a.cookie, + ) + ).status, + 201, + ); + const history = (await call('/positions/' + hidden, 'GET', undefined, a.cookie)).data.history; + assert.deepEqual( + history.map((h: { time: string }) => h.time), + ['2026-09-01T09:17', '2026-09-01T09:18'], + ); + assert.equal(history[1].delta, '15'); + const backup = (await call('/backup', 'GET', undefined, a.cookie)).data; + assert.equal(backup.version, 2); + assert.equal(backup.positions.find((p: { id: string }) => p.id === hidden).hidden, true); + assert.equal( + backup.positions.find((p: { id: string }) => p.id === hidden).revisions[0].date, + '2026-09-01T09:17', + ); + const c = await account(); + const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } }); + assert.match(download.headers.get('content-disposition')!, /\.zip/); + const bytes = await download.arrayBuffer(); + async function upload(cookie: string, content: ArrayBuffer | string) { + const form = new FormData(); + form.append('file', new Blob([content]), 'backup.zip'); + const res = await fetch(base + '/backup/upload', { + method: 'POST', + headers: { Origin: process.env.WEB_ORIGIN!, Cookie: cookie }, + body: form, + }); + return { status: res.status, data: await res.json() }; + } + assert.equal((await upload(c.cookie, 'invalid zip')).status, 400); + assert.equal(await db.position.count({ where: { userId: c.id } }), 0); + const uploaded = await upload(c.cookie, bytes); + assert.equal(uploaded.status, 201); + assert.equal( + ( + await call( + '/backup/import-file', + 'POST', + { confirmed: true, token: uploaded.data.token }, + b.cookie, + ) + ).status, + 400, + ); + assert.equal( + ( + await call( + '/backup/import-file', + 'POST', + { confirmed: false, token: uploaded.data.token }, + c.cookie, + ) + ).status, + 400, + ); + assert.equal( + ( + await call( + '/backup/import-file', + 'POST', + { confirmed: true, token: uploaded.data.token }, + c.cookie, + ) + ).status, + 201, + ); + assert.equal( + ( + await call( + '/backup/import-file', + 'POST', + { confirmed: true, token: uploaded.data.token }, + c.cookie, + ) + ).status, + 400, + ); + assert.equal((await call('/overview', 'GET', undefined, c.cookie)).data.net, '20.00'); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + 201, + ); + assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '27.00'); + await call('/auth/reveal', 'POST', { password: b.password }, b.cookie); + assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, '122.00'); + await db.session.update({ + where: { id: sessionId(a.cookie) }, + data: { revealUntil: new Date(Date.now() - 1000) }, + }); + assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '20.00'); + assert.equal( + (await call('/backup/clear', 'POST', { confirmation: '确定清空' }, otherSession.cookie)) + .status, + 400, + ); + assert.equal( + (await call('/backup/clear', 'POST', { confirmation: '清空' }, a.cookie)).status, + 400, + ); + await call( + '/positions/' + visible + '/revisions', + 'POST', + { amount: '21', date: '2026-09-01T10:12' }, + a.cookie, + ); + assert.equal( + (await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status, + 409, + ); + assert.equal(await db.position.count({ where: { userId: a.id } }), 2); + await call('/backup', 'GET', undefined, a.cookie); + assert.equal( + (await call('/backup/clear', 'POST', { confirmation: '确定清空', userId: b.id }, a.cookie)) + .status, + 400, + ); + assert.equal( + (await call('/backup/clear', 'POST', { confirmation: '确定清空' }, a.cookie)).status, + 201, + ); + assert.equal(await db.position.count({ where: { userId: a.id } }), 0); + assert.equal(await db.exchangeRate.count({ where: { userId: a.id } }), 0); + assert.equal(await db.position.count({ where: { userId: b.id } }), 3); + assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200); + assert.equal( + (await call('/settings', 'PATCH', { showSidebar: false, idleMinutes: 1 }, a.cookie)).status, + 200, + ); + const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data; + assert.equal(prefs.showSidebar, false); + assert.equal(prefs.idleMinutes, 1); + assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400); + await db.session.update({ + where: { id: sessionId(a.cookie) }, + data: { lastActivity: new Date(Date.now() - 61000) }, + }); + assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401); + assert.equal((await call('/auth/activity', 'POST', undefined, a.cookie)).status, 401); + assert.equal((await call('/auth/me', 'GET', undefined, b.cookie)).status, 200); + const legacy = { + ...backup, + version: 1, + positions: backup.positions.map((p: any) => { + const { hidden, ...rest } = p; + return { + ...rest, + revisions: p.revisions.map((r: any) => ({ ...r, date: r.date.slice(0, 10) })), + }; + }), + }; + assert.equal((await call('/auth/me', 'GET', undefined, otherSession.cookie)).status, 200); + const login = await call('/auth/login', 'POST', { username: a.username, password: a.password }); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup: legacy }, login.cookie)) + .status, + 201, + ); + assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 2); + } finally { + for (const username of names) await db.user.deleteMany({ where: { username } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts new file mode 100644 index 0000000..fd82136 --- /dev/null +++ b/apps/api/test/zip.test.ts @@ -0,0 +1,95 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { ZipArchive } from 'archiver'; +import { validateBackup } from '../src/backup'; +import { packBackup, readBackupZip } from '../src/zip'; +const empty = () => + validateBackup({ + format: 'worthpath', + version: 2, + exportedAt: new Date().toISOString(), + baseCurrency: 'CNY', + preferences: { showSidebar: false, idleMinutes: 9 }, + currencies: ['CNY'], + positions: [], + rates: [], + links: [], + }); +async function archive(contents: Record) { + const zip = new ZipArchive({ zlib: { level: 1 } }), + chunks: Buffer[] = []; + const done = new Promise((resolve, reject) => { + zip.on('data', (chunk) => chunks.push(chunk)); + zip.on('end', () => resolve(Buffer.concat(chunks))); + zip.on('error', reject); + }); + for (const [name, data] of Object.entries(contents)) zip.append(data, { name }); + await zip.finalize(); + return done; +} +test('ZIP contains separate JSON files and restores settings without authentication data', async () => { + const b = empty(), + contents = packBackup(b); + assert.deepEqual(Object.keys(contents).sort(), [ + 'accounts.json', + 'assets.json', + 'currencies.json', + 'debts.json', + 'history.json', + 'links.json', + 'manifest.json', + 'rates.json', + 'settings.json', + ]); + assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i); + assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b); +}); +test('ZIP rejects incomplete files, tampering, unknown entries and invalid entry names', async () => { + const contents = packBackup(empty()); + await assert.rejects(async () => + readBackupZip(await archive({ ...contents, 'settings.json': '{}' })), + ); + const missing = { ...contents }; + delete missing['history.json']; + await assert.rejects(async () => readBackupZip(await archive(missing))); + await assert.rejects(async () => + readBackupZip(await archive({ ...contents, 'unexpected.json': '[]' })), + ); + await assert.rejects(() => readBackupZip(Buffer.from('invalid zip'))); +}); +test('backup accepts over 1000 positions, 10000 revisions per position and 20000 total revisions', () => { + const stamp = new Date().toISOString(); + const position = (count: number) => ({ + id: randomUUID(), + name: 'count acceptance', + kind: 'asset', + side: 'asset', + category: 'other', + currency: 'CNY', + notes: '', + archived: false, + hidden: false, + createdAt: stamp, + updatedAt: stamp, + revisions: Array.from({ length: count }, (_, n) => ({ + id: randomUUID(), + sequence: n + 1, + amount: '1', + date: '2026-09-01T09:17', + notes: '', + reason: 'valuation', + createdAt: stamp, + updatedAt: stamp, + })), + }); + const b = validateBackup({ + ...empty(), + positions: [position(10001), ...Array.from({ length: 1000 }, () => position(11))], + }); + assert.equal(b.positions.length, 1001); + assert.equal( + b.positions.reduce((n, p) => n + p.revisions.length, 0), + 21001, + ); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 79ca28d..457fb33 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -23,6 +23,8 @@ import { currencies, money, today, + nowMinute, + displayTime, type User, type Position, type Overview, @@ -214,7 +216,11 @@ export default function App() { baseCurrency: string; currentBaseCurrency: string; } | null>(null); + const [clearStep, setClearStep] = useState(0); + const activityAt = useRef(0); function clearAccount() { + setClearStep(0); + activityAt.current = 0; sessionGeneration.current++; loadGeneration.current++; setUser(null); @@ -253,11 +259,20 @@ export default function App() { >('/settings'), ]); if (session !== sessionGeneration.current || request !== loadGeneration.current) return; - setPositions(p); + if (o.revealed !== !!s.revealed) { + setPositions([]); + setOverview(null); + setSelected(null); + setModal(null); + void load(); + return; + } + if (!activityAt.current && s.lastActivity) activityAt.current = +new Date(s.lastActivity); + setPositions(s.revealed ? p : p.filter((position) => !position.hidden)); setOverview(o); setRates(s.rates); setFxStatus(s.fxStatus); - setUser({ username: s.username, baseCurrency: s.baseCurrency }); + setUser(s); } catch (e) { if (session === sessionGeneration.current && request === loadGeneration.current) report(e); } finally { @@ -281,12 +296,61 @@ export default function App() { useEffect(() => { window.scrollTo({ top: 0 }); }, [page, selected]); + useEffect(() => { + if (!user) return; + const session = sessionGeneration.current; + let lastSent = 0, + pending = false, + revealExpired = false; + const activity = () => { + if (!document.hidden) { + activityAt.current = Date.now(); + if (Date.now() - lastSent > 15000 && !pending) { + pending = true; + lastSent = Date.now(); + void api('/auth/activity', 'POST') + .catch((e) => { + if (session === sessionGeneration.current) report(e); + }) + .finally(() => { + pending = false; + }); + } + } + }; + const check = window.setInterval(() => { + if ( + user.idleMinutes && + activityAt.current && + Date.now() - activityAt.current >= user.idleMinutes * 60000 + ) { + clearAccount(); + setError('长时间无操作,已自动退出登录'); + void api('/auth/logout', 'POST').catch(() => {}); + } else if (!revealExpired && user.revealUntil && Date.now() >= +new Date(user.revealUntil)) { + revealExpired = true; + setPositions([]); + setOverview(null); + setSelected(null); + setModal(null); + void load(); + } + }, 1000); + const events = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart']; + events.forEach((e) => window.addEventListener(e, activity, { passive: true })); + return () => { + window.clearInterval(check); + events.forEach((e) => window.removeEventListener(e, activity)); + }; + }, [user?.idleMinutes, user?.revealUntil, !!user]); async function act(work: () => Promise, message: string) { + const session = sessionGeneration.current; setBusy(true); setError(''); setSuccess(''); try { const result = await work(); + if (session !== sessionGeneration.current) return; setSuccess( result && typeof result === 'object' && @@ -298,7 +362,7 @@ export default function App() { setModal(null); await load(); } catch (e) { - report(e); + if (session === sessionGeneration.current) report(e); } finally { setBusy(false); } @@ -318,6 +382,7 @@ export default function App() { setPage('overview'); setSelected(null); setModal(null); + activityAt.current = Date.now(); setUser(u); await load(); } catch (e) { @@ -449,46 +514,64 @@ export default function App() { ['settings', Settings], ] as const; return ( -
- +

我的资产空间

+ +
+
{user.username.slice(0, 1).toUpperCase()}
+
+ {user.username} + 个人账户 · {user.baseCurrency} +
+ +
+ + )}
个人财务 / {labels[page]} + {user.showSidebar === false && ( + + )}
本位币 {user.baseCurrency}
+
+ + {overview?.revealed + ? '当前包含隐藏项目 · 验证 5 分钟后自动锁定' + : '当前不包含隐藏账户、资产和债务,净资产按此范围计算'} + + +

WORTHPATH / {today()}

@@ -691,6 +792,7 @@ export default function App() { {p.archived ? ' · 已归档' : ''}

{money(p.amount, p.currency)}

+ {p.hidden && 隐藏项目}

本位币: {overview?.items.find((i) => i.id === p.id)?.converted !== null @@ -718,6 +820,7 @@ export default function App() { category: p.category, notes: p.notes, archived: !p.archived, + hidden: p.hidden, }), p.archived ? '项目已恢复' : '项目已归档', ) @@ -830,7 +933,7 @@ export default function App() { p.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)} open={(id) => { setSelected(id); setPage(positions.find((p) => p.id === id)?.kind || 'account'); @@ -857,11 +960,40 @@ export default function App() { e.preventDefault(); const f = new FormData(e.currentTarget); void act( - () => api('/settings', 'PATCH', { baseCurrency: f.get('baseCurrency') }), - '本位币已更新,请检查换算汇率', + () => + api('/settings', 'PATCH', { + baseCurrency: f.get('baseCurrency'), + showSidebar: f.get('showSidebar') === 'true', + idleMinutes: Number(f.get('idleMinutes')), + }), + '个人设置已保存', ); }} > + + + + + + +

+ 0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。 +

+ + + + )} + {clearStep > 0 && ( + + )} +

数据备份与恢复

- 可读的版本化 - JSON,包括项目、历史、关联、币种和汇率;不包含任何认证凭据。备份含个人财务信息,请妥善保管。 + ZIP 内分文件保存可读 + JSON,包括全部账户、资产、债务、历史、关联、币种、设置和汇率,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。

setSuccess('备份下载请求已发起,请检查浏览器下载列表')} > @@ -971,7 +1155,7 @@ export default function App() { 选择备份并验证 { setBackup(null); @@ -982,15 +1166,20 @@ export default function App() { const uploadSession = sessionGeneration.current; setBusy(true); try { - if (file.size > 8 * 1024 * 1024) throw new Error('文件不能超过 8 MB'); - const b = JSON.parse(await file.text()), - result = await api>( - '/backup/preview', - 'POST', - b, - ); + if (file.size > 512 * 1024 * 1024) + throw new Error('ZIP 文件不能超过 512 MB(不限制记录条数)'); + const form = new FormData(); + form.append('file', file); + const response = await fetch('/api/backup/upload', { + method: 'POST', + body: form, + credentials: 'same-origin', + }); + const result = await response.json(); + if (!response.ok) + throw new ApiError(result.message || 'ZIP 验证失败', response.status); if (uploadSession !== sessionGeneration.current) return; - setBackup(b); + setBackup(result.token); setPreview(result); } catch (err) { if (uploadSession === sessionGeneration.current) report(err); @@ -1018,7 +1207,10 @@ export default function App() { disabled={busy} onClick={() => void act(async () => { - await api('/backup/import', 'POST', { confirmed: true, backup }); + await api('/backup/import-file', 'POST', { + confirmed: true, + token: backup, + }); setPreview(null); setBackup(null); }, '备份已完整导入') @@ -1046,14 +1238,14 @@ export default function App() { {modal && ( api('/auth/reveal', 'POST', { password: f.get('password') }), + '隐藏项目已解锁 5 分钟', + ); + } else if (kind === 'links') { void act( () => api('/positions/' + mp!.id + '/links', 'PUT', { @@ -1077,8 +1274,6 @@ export default function App() { }), '关联已更新', ); - } else if (kind === 'rate') { - void act(() => api('/rates', 'PUT', v), '汇率已保存,同日已有汇率已更正'); } else if (kind === 'revision' || kind === 'correction') { void act( () => @@ -1091,7 +1286,12 @@ export default function App() { ); } else if (kind === 'edit') { void act( - () => api('/positions/' + mp!.id, 'PATCH', { ...v, archived: mp!.archived }), + () => + api('/positions/' + mp!.id, 'PATCH', { + ...v, + archived: mp!.archived, + hidden: f.get('hidden') === 'on', + }), '项目资料已保存', ); } else { @@ -1101,12 +1301,36 @@ export default function App() { (kind === 'account' && ['credit_card', 'loan'].includes(category)) ? 'liability' : 'asset'; - void act(() => api('/positions', 'POST', { ...v, kind, side }), '项目已添加'); + void act( + () => + api('/positions', 'POST', { + ...v, + kind, + side, + hidden: f.get('hidden') === 'on', + }), + '项目已添加', + ); } }} > + {modal.kind === 'reveal' && ( + + + + )} {['account', 'asset', 'debt', 'edit'].includes(modal.kind) && ( <> + )} - {['account', 'asset', 'debt', 'rate'].includes(modal.kind) && ( + {['account', 'asset', 'debt'].includes(modal.kind) && ( )} - {modal.kind === 'rate' && ( - <> - - - - - - -

保存将更正同币种、同日期的已有汇率,历史总额会重新计算。

- - )} {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( )} - {['account', 'asset', 'debt', 'revision', 'correction', 'rate'].includes( - modal.kind, - ) && ( - + {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( + )} @@ -1233,7 +1435,7 @@ export default function App() { )}
) : ( - modal.kind !== 'rate' && ( + modal.kind !== 'reveal' && (