fix: account credentials, record deletion refresh and backup recovery

This commit is contained in:
陈煜 committed 2026-10-03 11:23:19 +08:00
1 parent 9adf7fdbc5
commit e77650f0c0
22 files changed
+711 -115

No files matched your search

+45 -2
View File
@@ -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({
+14 -15
View File
@@ -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<number>();
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');
}
+4 -3
View File
@@ -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 || '' : '',
};
})
+1
View File
@@ -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.
+2
View File
@@ -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<string, z.ZodType> = {
'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,
+12 -3
View File
@@ -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 };
+2 -3
View File
@@ -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('修改后债务或资产金额不能为负数');
+2 -2
View File
@@ -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'))
+69 -47
View File
@@ -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<typeof transferInput.parse>) {
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<UserRequest, 'userId' | 'revealed'>,
id: string,
v?: ReturnType<typeof transferInput.parse>,
) {
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<UserRequest, 'userId' | 'revealed'>,
@@ -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'))
+9
View File
@@ -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, '请填写新的账号或密码');