fix: account credentials, record deletion refresh and backup recovery
This commit is contained in:
1 parent
9adf7fdbc5
commit
e77650f0c0
22 files changed
+711
-115
No files matched your search
+45
-2
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 || '' : '',
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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('修改后债务或资产金额不能为负数');
|
||||
|
||||
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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, '请填写新的账号或密码');
|
||||
Reference in new issue
Block a user