From e77650f0c0acea740f3073cb602cf65a5615cd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sat, 3 Oct 2026 11:23:19 +0800 Subject: [PATCH] fix: account credentials, record deletion refresh and backup recovery --- apps/api/package.json | 2 +- apps/api/src/auth.ts | 47 ++++- apps/api/src/backup.ts | 29 ++- apps/api/src/calendar.ts | 7 +- apps/api/src/database.ts | 1 + apps/api/src/openapi.ts | 2 + apps/api/src/portfolio.ts | 15 +- apps/api/src/replay.ts | 5 +- apps/api/src/schedules.ts | 4 +- apps/api/src/transfers.ts | 116 +++++++----- apps/api/src/validation.ts | 9 + apps/api/test/record-edit.test.ts | 19 +- apps/api/test/security-backup.test.ts | 247 ++++++++++++++++++++++++++ apps/web/src/App.tsx | 236 ++++++++++++++++++++---- apps/web/src/locales/en.json | 21 ++- apps/web/src/locales/zh-Hant.json | 21 ++- apps/web/src/style.css | 7 + docs/acceptance.md | 4 + docs/architecture.md | 4 +- docs/delete-record-preview.png | Bin 0 -> 22235 bytes docs/update-2026-10-03.md | 24 +++ update.md | 6 + 22 files changed, 711 insertions(+), 115 deletions(-) create mode 100644 apps/api/test/security-backup.test.ts create mode 100644 docs/delete-record-preview.png create mode 100644 docs/update-2026-10-03.md diff --git a/apps/api/package.json b/apps/api/package.json index 4b6d66f..079dd3d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,7 +9,7 @@ "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", - "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts", + "test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts", "test:performance": "tsx scripts/performance.ts after", "icons:seed": "node scripts/seed-icons.cjs" }, diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 2719eb3..35f4ffa 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -3,6 +3,8 @@ import { Controller, Get, Post, + Patch, + BadRequestException, Body, Req, Res, @@ -18,7 +20,8 @@ import { Request, Response } from 'express'; import { randomBytes, createHash } from 'node:crypto'; import { hash, compare } from 'bcryptjs'; import { Database } from './database'; -import { credentials } from './validation'; +import { credentials, credentialChange } from './validation'; +import { Prisma } from '@prisma/client'; 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'); @@ -58,6 +61,9 @@ export class AuthService { const token = randomBytes(32).toString('hex'), expiresAt = new Date(Date.now() + 7 * 86400000); await this.db.session.create({ data: { id: digest(token), userId, expiresAt } }); + this.cookie(token, expiresAt, res); + } + cookie(token: string, expiresAt: Date, res: Response) { res.cookie('wp_session', token, { httpOnly: true, sameSite: 'strict', @@ -166,7 +172,44 @@ export class AuthController { idleMinutes: true, }, }); - return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) }; + const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } }); + return { + ...user, + hiddenMenus: user.hiddenMenus.split(',').filter(Boolean), + revealed: req.revealed, + revealUntil: session.revealUntil, + lastActivity: session.lastActivity, + }; + } + @Patch('auth/credentials') async changeCredentials( + @Req() r: UserRequest, + @Body() body: unknown, + @Res({ passthrough: true }) res: Response, + ) { + this.auth.limit(r); + const v = credentialChange.parse(body); + const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (!(await compare(v.currentPassword, user.passwordHash))) + throw new ForbiddenException('当前密码错误'); + if ((!v.username || v.username === user.username) && !v.newPassword) + throw new BadRequestException('请填写新的账号或密码'); + const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash; + const token = randomBytes(32).toString('hex'), + expiresAt = new Date(Date.now() + 7 * 86400000); + await this.db.serial(async (tx) => { + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`); + const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } }); + if (current.passwordHash !== user.passwordHash || current.username !== user.username) + throw new ForbiddenException('账号已变更,请重新登录后操作'); + await tx.user.update({ + where: { id: r.userId }, + data: { username: v.username, passwordHash }, + }); + await tx.session.deleteMany({ where: { userId: r.userId } }); + await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } }); + }); + this.auth.cookie(token, expiresAt, res); + return { ok: true }; } @Post('auth/activity') async activity(@Req() r: UserRequest) { await this.db.session.update({ diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 93f09e3..369cd87 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -56,16 +56,14 @@ const record = positionMeta importedFromId: z.string().uuid().nullable().optional(), createdAt: timestamp, updatedAt: timestamp, - revisions: z - .array( - revisionInput.extend({ - id: z.string().uuid(), - sequence: z.number().int().positive().max(2147483647).optional(), - createdAt: timestamp, - updatedAt: timestamp, - }), - ) - .min(1), + revisions: z.array( + revisionInput.extend({ + id: z.string().uuid(), + sequence: z.number().int().positive().max(2147483647).optional(), + createdAt: timestamp, + updatedAt: timestamp, + }), + ), }) .strict(); const backupSchema = z @@ -151,7 +149,7 @@ export function validateBackup(raw: unknown) { archived: p.archived, hidden: p.hidden, amount: '0', - date: p.revisions[0].date, + date: p.revisions[0]?.date || '1900-01-01', }); const sequences = new Set(); for (const r of p.revisions) { @@ -225,11 +223,11 @@ export function validateBackup(raw: unknown) { current = ordered[index]; if ( usedRevisions.has(revId) || - index < 1 || + index < 0 || !current || current.reason !== reason || current.date !== t.date || - !new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta) + !new Decimal(current.amount).minus(ordered[index - 1]?.amount || '0').eq(delta) ) throw new BadRequestException('转账历史与双方金额不一致'); usedRevisions.add(revId); @@ -296,7 +294,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { } return prefix.toString() === 'PK' ? readBackupZip(path) - : JSON.parse(await readFile(path, 'utf8')); + : JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')); } constructor(private db: Database) {} private async data( @@ -420,7 +418,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { }); return b; }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 }, ); res.setHeader( 'Content-Disposition', @@ -494,6 +492,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { ); data.currencies.sort(); data.transfers?.sort((a, b) => a.id.localeCompare(b.id)); + data.schedules?.sort((a, b) => a.id.localeCompare(b.id)); data.icons?.sort((a, b) => a.id.localeCompare(b.id)); return createHash('sha256').update(JSON.stringify(data)).digest('hex'); } diff --git a/apps/api/src/calendar.ts b/apps/api/src/calendar.ts index 6a5a82b..b93011f 100644 --- a/apps/api/src/calendar.ts +++ b/apps/api/src/calendar.ts @@ -71,9 +71,10 @@ export class CalendarController { name: p.name, currency: p.currency, date: businessDay(v.effectiveDate), - delta: hasBefore - ? cashflowDelta(p.side, v.reason, before, v.amount.toString()) - : new Decimal(0), + delta: + hasBefore || v.reason === 'scheduled_expense' + ? cashflowDelta(p.side, v.reason, before, v.amount.toString()) + : new Decimal(0), notes: details ? (v as typeof v & { notes?: string }).notes || '' : '', }; }) diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 276fcb5..26ac234 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -7,6 +7,7 @@ export class Database extends PrismaClient implements OnModuleInit, OnModuleDest try { return await this.$transaction(work, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + timeout: 30000, }); } catch (error) { // Serializable deadlock/write conflict; retry the entire atomic operation. diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 926a7c6..4c2f0da 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -4,6 +4,7 @@ import { scheduleInput } from './schedules'; import { z } from 'zod'; import { credentials, + credentialChange, positionInput, positionMeta, revisionInput, @@ -27,6 +28,7 @@ export function setupOpenApi(app: INestApplication) { const bodies: Record = { 'POST /api/auth/register': credentials, 'POST /api/auth/login': credentials, + 'PATCH /api/auth/credentials': credentialChange, 'POST /api/auth/reveal': credentials.pick({ password: true }), 'POST /api/positions': positionInput, 'PATCH /api/positions/{id}': positionMeta, diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 200c703..70ac284 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -30,6 +30,7 @@ import { Prisma } from '@prisma/client'; import { IconsService } from './icons'; import { RatesService } from './rates'; import { captureReplay } from './replay'; +import { changeMovement } from './transfers'; @Controller('api') export class PortfolioController { constructor( @@ -229,7 +230,6 @@ export class PortfolioController { @Param('revisionId') revisionId: string, ) { return this.db.serial(async (tx) => { - const replay = await captureReplay(tx, r.userId, [id]); const p = await tx.position.findFirst({ where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, }); @@ -237,8 +237,17 @@ export class PortfolioController { if (p.archived) throw new ConflictException('请先恢复归档项目'); const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } }); if (!row) throw new NotFoundException('历史记录不存在'); - if (pairedReasons.includes(row.reason)) - throw new ConflictException('请在资金往来中删除完整配对记录'); + if (pairedReasons.includes(row.reason)) { + const movement = await tx.transfer.findFirst({ + where: { + userId: r.userId, + OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }], + }, + }); + if (!movement) throw new ConflictException('配对记录不完整,无法删除'); + return changeMovement(tx, r, movement.id); + } + const replay = await captureReplay(tx, r.userId, [id]); await tx.revision.delete({ where: { id: revisionId } }); await replay(); return { ok: true }; diff --git a/apps/api/src/replay.ts b/apps/api/src/replay.ts index a65481f..4aa38f6 100644 --- a/apps/api/src/replay.ts +++ b/apps/api/src/replay.ts @@ -51,10 +51,9 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string : undefined; if (pairedReasons.includes(row.reason) && !delta) throw new ConflictException('配对记录不完整,无法重算'); - if (delta && !balances.has(row.positionId)) - throw new ConflictException('资金操作之前必须保留一条余额记录'); + // With the initial observation deleted, remaining movements start at zero. const amount = delta - ? balances.get(row.positionId)!.plus(delta) + ? (balances.get(row.positionId) || new Decimal(0)).plus(delta) : new Decimal(row.amount.toString()); if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset')) throw new BadRequestException('修改后债务或资产金额不能为负数'); diff --git a/apps/api/src/schedules.ts b/apps/api/src/schedules.ts index 98b9108..f697e45 100644 --- a/apps/api/src/schedules.ts +++ b/apps/api/src/schedules.ts @@ -222,8 +222,8 @@ export class SchedulesController { revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 }, }, }); - if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用'); - const after = new Decimal(account.revisions[0].amount.toString()).minus( + if (!account) throw new BadRequestException('计划账户已归档或不可用'); + const after = new Decimal(account.revisions[0]?.amount.toString() || '0').minus( plan.amount.toString(), ); if (after.abs().gte('10000000000000000')) diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts index 6afd3da..3cd18cd 100644 --- a/apps/api/src/transfers.ts +++ b/apps/api/src/transfers.ts @@ -48,6 +48,25 @@ export class TransfersController { revealed: r.revealed, }; } + @Get('revision/:revisionId') async byRevision( + @Req() r: UserRequest, + @Param('revisionId') revisionId: string, + ) { + const row = await this.db.transfer.findFirst({ + where: { + userId: r.userId, + OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }], + ...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }), + }, + include: { + source: { select: { name: true, kind: true } }, + target: { select: { name: true, kind: true } }, + }, + }); + if (!row) throw new NotFoundException('资金往来记录不存在'); + const { userId, importedFromId, effectiveDate, ...v } = row; + return { ...v, date: businessTime(effectiveDate) }; + } @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) { const v = transferInput.parse(body); return this.change(r, id, v); @@ -57,50 +76,7 @@ export class TransfersController { } private async change(r: UserRequest, id: string, v?: ReturnType) { return this.db.serial(async (tx) => { - const row = await tx.transfer.findFirst({ - where: { - id, - userId: r.userId, - ...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }), - }, - include: { source: true, target: true }, - }); - if (!row) throw new NotFoundException('资金往来记录不存在'); - if (row.source.archived || row.target.archived) - throw new ConflictException('请先恢复归档项目'); - const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]); - if (v) { - if ( - v.sourceId !== row.sourceId || - v.targetId !== row.targetId || - v.operation !== row.operation - ) - throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建'); - if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received)) - throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写'); - const effectiveDate = toBusinessDate(v.date); - await tx.transfer.update({ - where: { id }, - data: { - amount: v.amount, - received: v.received, - fee: v.fee, - notes: v.notes, - effectiveDate, - }, - }); - await tx.revision.updateMany({ - where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } }, - data: { effectiveDate, notes: v.notes }, - }); - } else { - await tx.transfer.delete({ where: { id } }); - await tx.revision.deleteMany({ - where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } }, - }); - } - await replay(); - return { ok: true }; + return changeMovement(tx, r, id, v); }); } @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { @@ -108,6 +84,52 @@ export class TransfersController { return this.db.serial((tx) => executeMovement(tx, r, v)); } } +export async function changeMovement( + tx: Prisma.TransactionClient, + r: Pick, + id: string, + v?: ReturnType, +) { + const row = await tx.transfer.findFirst({ + where: { + id, + userId: r.userId, + ...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }), + }, + include: { source: true, target: true }, + }); + if (!row) throw new NotFoundException('资金往来记录不存在'); + if (row.source.archived || row.target.archived) throw new ConflictException('请先恢复归档项目'); + const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]); + if (v) { + if (v.sourceId !== row.sourceId || v.targetId !== row.targetId || v.operation !== row.operation) + throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建'); + if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received)) + throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写'); + const effectiveDate = toBusinessDate(v.date); + await tx.transfer.update({ + where: { id }, + data: { + amount: v.amount, + received: v.received, + fee: v.fee, + notes: v.notes, + effectiveDate, + }, + }); + await tx.revision.updateMany({ + where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } }, + data: { effectiveDate, notes: v.notes }, + }); + } else { + await tx.transfer.delete({ where: { id } }); + await tx.revision.deleteMany({ + where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } }, + }); + } + await replay(); + return { ok: true }; +} export async function executeMovement( tx: Prisma.TransactionClient, r: Pick, @@ -166,14 +188,14 @@ export async function executeMovement( target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset')) ) throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务'); - if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when)) + if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when)) throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账'); if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received)) throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写'); const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee); - const before = new Decimal(source.revisions[0].amount.toString()); + const before = new Decimal(source.revisions[0]?.amount.toString() || '0'); const sourceAfter = before.plus(deltas.source); - const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target); + const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target); if (target.kind === 'debt' && after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务'); if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000')) diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index c0b770a..c0ae557 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -173,3 +173,12 @@ export const pairedReasons = [ 'loan_collect', 'loan_repay', ]; + +export const credentialChange = z + .object({ + currentPassword: credentials.shape.password, + username: credentials.shape.username.optional(), + newPassword: credentials.shape.password.optional(), + }) + .strict() + .refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码'); diff --git a/apps/api/test/record-edit.test.ts b/apps/api/test/record-edit.test.ts index 0d8a832..8651ab1 100644 --- a/apps/api/test/record-edit.test.ts +++ b/apps/api/test/record-edit.test.ts @@ -119,7 +119,24 @@ test('record edits replay paired movements, expense deltas, anchors and calendar await portfolio.deleteRevision(r, a.id, expense.id); assert.equal(await balance(a.id), '190'); assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0); - await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id)); + await portfolio.deleteRevision(r, a.id, a.revisions[0].id); + assert.equal(await balance(a.id), '-10'); + const restoredInitial = await db.revision.create({ + data: { + positionId: a.id, + amount: '200', + effectiveDate: toBusinessDate('2026-09-01T00:00'), + reason: 'initial', + notes: '', + }, + }); + a.revisions[0].id = restoredInitial.id; + // Replay after restoring a baseline through the correction API. + await portfolio.correct(r, a.id, restoredInitial.id, { + amount: '200', + date: '2026-09-01T00:00', + notes: '', + }); assert.equal(await balance(a.id), '190'); // An observed absolute balance is an anchor, including after a moved transfer. await portfolio.revise(r, a.id, { diff --git a/apps/api/test/security-backup.test.ts b/apps/api/test/security-backup.test.ts new file mode 100644 index 0000000..d8cdd1d --- /dev/null +++ b/apps/api/test/security-backup.test.ts @@ -0,0 +1,247 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { hash } from 'bcryptjs'; +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('credentials rotate sessions; deleting paired and empty histories preserves ZIP recovery', async () => { + const db = new PrismaClient(), + ids: string[] = []; + const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; + async function call(path: string, cookie = '', method = 'GET', body?: unknown) { + const res = await fetch(base + path, { + method, + headers: { + Cookie: cookie, + Origin: origin, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { + status: res.status, + data: await res.json(), + cookie: res.headers.get('set-cookie')?.split(';')[0] || '', + }; + } + async function session(userId: string) { + const token = randomBytes(32).toString('hex'); + await db.session.create({ + data: { + id: createHash('sha256').update(token).digest('hex'), + userId, + expiresAt: new Date(Date.now() + 3600000), + }, + }); + return 'wp_session=' + token; + } + async function user() { + const username = 'wp_fix_' + randomUUID(), + password = randomBytes(18).toString('hex'); + const user = await db.user.create({ + data: { username, passwordHash: await hash(password, 12) }, + }); + ids.push(user.id); + const cookie = await session(user.id); + return { id: user.id, username, password, cookie }; + } + try { + const a = await user(), + b = await user(); + const other = { cookie: await session(a.id) }; + assert.equal( + ( + await call('/auth/credentials', a.cookie, 'PATCH', { + username: b.username, + currentPassword: 'wrong-password', + }) + ).status, + 403, + ); + assert.equal( + ( + await call('/auth/credentials', a.cookie, 'PATCH', { + username: b.username, + currentPassword: a.password, + }) + ).status, + 409, + ); + assert.equal((await call('/auth/me', other.cookie)).status, 200); + const username = 'wp_changed_' + randomUUID(), + password = randomBytes(18).toString('hex'); + const changed = await call('/auth/credentials', a.cookie, 'PATCH', { + username, + currentPassword: a.password, + newPassword: password, + }); + assert.equal(changed.status, 200); + a.cookie = changed.cookie; + assert.equal((await call('/auth/me', other.cookie)).status, 401); + assert.equal((await call('/auth/me', a.cookie)).data.username, username); + assert.equal( + (await call('/auth/login', '', 'POST', { username: a.username, password: a.password })) + .status, + 401, + ); + assert.equal( + (await call('/auth/login', '', 'POST', { username, password: a.password })).status, + 401, + ); + assert.equal((await call('/auth/login', '', 'POST', { username, password })).status, 201); + await db.session.updateMany({ + where: { userId: a.id }, + data: { revealUntil: new Date(Date.now() + 300000) }, + }); + assert.equal((await call('/auth/me', a.cookie)).data.revealed, true); + async function create(name: string, amount: string) { + const result = await call('/positions', a.cookie, 'POST', { + name, + amount, + kind: 'account', + side: 'asset', + category: 'bank', + currency: 'CNY', + date: '2026-09-01T00:00', + }); + assert.equal(result.status, 201); + return result.data.id; + } + const sourceId = await create('source', '100'), + targetId = await create('target', '0'), + emptyId = await create('empty', '1'); + const rev = (id: string) => + db.revision.findFirstOrThrow({ where: { positionId: id }, orderBy: { sequence: 'asc' } }); + const remove = (id: string, revisionId: string, cookie = a.cookie) => + call('/positions/' + id + '/revisions/' + revisionId, cookie, 'DELETE'); + assert.equal((await remove(emptyId, (await rev(emptyId)).id)).status, 200); + const movement = await call('/transfers', a.cookie, 'POST', { + sourceId, + targetId, + amount: '10', + received: '10', + fee: '0', + date: '2026-09-02T00:00', + }); + assert.equal(movement.status, 201); + const pair = await db.transfer.findUniqueOrThrow({ where: { id: movement.data.id } }); + assert.equal((await remove(sourceId, pair.sourceRevisionId, b.cookie)).status, 404); + assert.equal((await remove(sourceId, (await rev(sourceId)).id)).status, 200); + assert.equal((await remove(targetId, (await rev(targetId)).id)).status, 200); + assert.equal( + (await call('/transfers/revision/' + pair.sourceRevisionId, a.cookie)).data.id, + pair.id, + ); + const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } }); + assert.equal(download.status, 200); + assert.equal(download.headers.get('content-type')?.includes('application/zip'), true); + const bytes = Buffer.from(await download.arrayBuffer()); + const backup: any = await readBackupZip(bytes); + assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0); + assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10'); + const form = new FormData(); + form.append('file', new Blob([bytes]), 'backup.zip'); + const upload = await fetch(base + '/backup/upload', { + method: 'POST', + headers: { Cookie: b.cookie, Origin: origin }, + body: form, + }); + assert.equal(upload.status, 201); + const preview: any = await upload.json(); + assert.equal( + ( + await call('/backup/import-file', a.cookie, 'POST', { + token: preview.token, + confirmed: true, + }) + ).status, + 400, + ); + assert.equal( + ( + await call('/backup/import-file', b.cookie, 'POST', { + token: preview.token, + confirmed: true, + }) + ).status, + 201, + ); + const restored = (await call('/positions', b.cookie)).data; + assert.equal(restored.find((p: any) => p.name === 'source').amount, '-10'); + assert.equal(restored.find((p: any) => p.name === 'target').amount, '10'); + assert.equal(restored.find((p: any) => p.name === 'empty').amount, '0'); + await db.position.update({ where: { id: targetId }, data: { hidden: true } }); + await call('/auth/lock', a.cookie, 'POST'); + assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404); + assert.equal(await db.transfer.count({ where: { id: pair.id } }), 1); + await db.session.updateMany({ + where: { userId: a.id }, + data: { revealUntil: new Date(Date.now() + 300000) }, + }); + assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 200); + assert.equal(await db.transfer.count({ where: { id: pair.id } }), 0); + assert.equal( + await db.revision.count({ + where: { id: { in: [pair.sourceRevisionId, pair.targetRevisionId] } }, + }), + 0, + ); + assert.equal((await call('/positions/' + sourceId, a.cookie)).data.amount, '0'); + assert.equal((await call('/positions/' + targetId, a.cookie)).data.amount, '0'); + assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404); + const minute = (delta = 0) => + new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16); + const plan = await call('/schedules', a.cookie, 'POST', { + name: 'Empty account expense', + operation: 'expense', + sourceId, + targetId: null, + amount: '2', + received: '0', + nextAt: minute(-60000), + intervalDays: 0, + notes: '', + }); + assert.equal(plan.status, 201); + assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1); + const today = minute().slice(0, 10); + const calendar = (await call('/calendar?month=' + today.slice(0, 7), a.cookie)).data; + assert.equal( + calendar.items.find((v: any) => v.date === minute(-60000).slice(0, 10)).expense, + '2.00', + ); + assert.equal( + ( + await call('/transfers', a.cookie, 'POST', { + sourceId, + targetId, + amount: '3', + received: '3', + fee: '0', + date: minute(), + }) + ).status, + 201, + ); + const emptyAccountBackup = await fetch(base + '/backup', { headers: { Cookie: a.cookie } }); + assert.equal(emptyAccountBackup.status, 200); + const emptyAccountData: any = await readBackupZip( + Buffer.from(await emptyAccountBackup.arrayBuffer()), + ); + assert.equal( + emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount, + '-5', + ); + assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409); + const fresh = await user(); + assert.equal( + (await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status, + 201, + ); + } finally { + await db.user.deleteMany({ where: { id: { in: ids } } }); + await db.$disconnect(); + } +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index e402c7a..1676651 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -258,6 +258,8 @@ export default function App() { ), [rangeTo, setRangeTo] = useState(today()), [grain, setGrain] = useState('day'); + const [dataVersion, setDataVersion] = useState(0); + const previousDataVersion = useRef(0); const viewKey = [page, selected, rangeFrom, rangeTo, grain, settingsSection].join('|'); const viewRef = useRef(viewKey); viewRef.current = viewKey; @@ -296,7 +298,7 @@ export default function App() { clearAccount(); } }; - async function load(refreshUser = false, rangeOnly = false) { + async function load(refreshUser = false, rangeOnly = false, runDue = true) { const session = sessionGeneration.current, request = ++loadGeneration.current, view = viewRef.current; @@ -308,6 +310,8 @@ export default function App() { try { const s = refreshUser ? await api('/auth/me') : userRef.current; if (!active() || !s) return; + const visibilityChanged = !!s.revealed !== !!userRef.current?.revealed; + if (visibilityChanged) rangeOnly = false; if (refreshUser) { setUser(s); setPage((current) => (s.hiddenMenus.includes(current) ? 'settings' : current)); @@ -315,7 +319,7 @@ export default function App() { setPositions((rows) => rows.filter((p) => !p.hidden)); setHistoryRows([]); setTransfers([]); - setOverview(null); + if (visibilityChanged) setOverview(null); } } if (page === 'overview' && rangeOnly) { @@ -346,7 +350,7 @@ export default function App() { const rows = await api('/positions?kind=account'); if (active()) setPositions(rows); } else if (['account', 'asset', 'debt'].includes(page)) { - if (page === 'account' && !rangeOnly) { + if (page === 'account' && !rangeOnly && runDue) { const result = await api<{ executed: number; errors: { message: string }[]; @@ -489,19 +493,21 @@ export default function App() { const previousView = useRef(''); useEffect(() => { if (!user) return; + const mutated = previousDataVersion.current !== dataVersion; + previousDataVersion.current = dataVersion; const rangeOnly = - page === 'overview' && previousView.current.startsWith('overview|') && !!overview; + !mutated && page === 'overview' && previousView.current.startsWith('overview|') && !!overview; previousView.current = viewKey; if (!rangeOnly) setOverview(null); setHistoryRows([]); setHistoryCursor(null); setTransfers([]); setTransferCursor(null); - void load(false, rangeOnly); + void load(true, rangeOnly, !mutated); return () => { loadGeneration.current++; }; - }, [viewKey, !!user]); + }, [viewKey, !!user, dataVersion]); useEffect(() => { const session = sessionGeneration.current; authCheck.current ||= api('/auth/me'); @@ -598,7 +604,7 @@ export default function App() { setPage(createdKind); setSelected(result.id); } - await load(true); + setDataVersion((value) => value + 1); } catch (e) { if (session === sessionGeneration.current) report(e); } finally { @@ -748,8 +754,15 @@ export default function App() { ); async function correctHistory(h: History) { if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) { - setSelected(null); - setPage('history'); + const session = sessionGeneration.current, + view = viewRef.current; + try { + const transfer = await api('/transfers/revision/' + h.id); + if (session === sessionGeneration.current && view === viewRef.current) + setModal({ kind: 'transfer-edit', transfer }); + } catch (e) { + if (session === sessionGeneration.current && view === viewRef.current) report(e); + } return; } const session = sessionGeneration.current, @@ -763,11 +776,40 @@ export default function App() { } } function deleteHistory(h: History) { - if (!window.confirm(tr('删除这条余额记录并重算后续余额?'))) return; - void act( - () => api('/positions/' + h.positionId + '/revisions/' + h.id, 'DELETE'), - tr('记录已删除,余额和日历已更新'), - ); + setError(''); + setModal({ kind: 'delete-record', h }); + } + async function downloadBackup(forClear = false) { + const session = sessionGeneration.current; + setBusy(true); + setError(''); + setSuccess(''); + try { + const response = await fetch('/api/backup', { credentials: 'same-origin' }); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + throw new ApiError(result.message || tr('备份下载失败'), response.status); + } + if (!response.headers.get('content-type')?.includes('application/zip')) + throw new Error(tr('备份文件格式错误')); + const blob = await response.blob(); + if (!blob.size) throw new Error(tr('备份文件为空')); + if (session !== sessionGeneration.current) return; + const url = URL.createObjectURL(blob), + a = document.createElement('a'); + a.href = url; + a.download = 'worthpath-' + today() + '.zip'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60000); + setSuccess(tr('备份已生成,请确认文件已保存')); + if (forClear) setClearStep(1); + } catch (e) { + if (session === sessionGeneration.current) report(e); + } finally { + if (session === sessionGeneration.current) setBusy(false); + } } const nav = [ ['overview', LayoutDashboard], @@ -1241,6 +1283,7 @@ export default function App() { rows={historyRows} correct={p.archived ? undefined : (h) => void correctHistory(h)} remove={p.archived ? undefined : deleteHistory} + busy={busy} /> {historyCursor && ( + + + )}