feat: customize sessions and overview with faster account workflows

This commit is contained in:
陈煜 committed 2026-10-03 18:07:19 +08:00
1 parent 9a133e82e9
commit 425b45c91a
40 files changed
+1656 -335

No files matched your search

+20 -8
View File
@@ -20,7 +20,7 @@ import { Request, Response } from 'express';
import { randomBytes, createHash } from 'node:crypto';
import { hash, compare } from 'bcryptjs';
import { Database } from './database';
import { credentials, credentialChange } from './validation';
import { credentials, credentialChange, defaultOverviewCards } from './validation';
import { Prisma } from '@prisma/client';
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
const Public = () => SetMetadata('public', true);
@@ -58,8 +58,9 @@ export class AuthService {
}
}
async issue(userId: string, res: Response) {
const user = await this.db.user.findUniqueOrThrow({ where: { id: userId } });
const token = randomBytes(32).toString('hex'),
expiresAt = new Date(Date.now() + 7 * 86400000);
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
this.cookie(token, expiresAt, res);
}
@@ -171,12 +172,17 @@ export class AuthController {
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
},
});
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
return {
...user,
accountGroupOrder: user.accountGroupOrder || [],
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
sessionExpiresAt: session.expiresAt,
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
revealed: req.revealed,
revealUntil: session.revealUntil,
@@ -197,7 +203,7 @@ export class AuthController {
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);
expiresAt = new Date(Date.now() + user.sessionHours * 3600000);
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 } });
@@ -222,11 +228,17 @@ export class AuthController {
}
@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 } });
const revealUntil = await this.db.serial(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
const u = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
if (u.requireHiddenPassword) {
const { password } = credentials.pick({ password: true }).parse(b);
if (!(await compare(password, u.passwordHash))) throw new ForbiddenException('密码错误');
}
const until = new Date(Date.now() + 5 * 60000);
await tx.session.update({ where: { id: r.sessionId }, data: { revealUntil: until } });
return until;
});
return { revealUntil };
}
@Post('auth/lock') async lock(@Req() r: UserRequest) {
+17
View File
@@ -36,6 +36,9 @@ import {
rateInput,
hiddenMenus,
accountGroupOrder,
sessionHours,
overviewCards,
defaultOverviewCards,
transferInput,
} from './validation';
import { createHash } from 'node:crypto';
@@ -79,6 +82,9 @@ const backupSchema = z
showSidebar: z.boolean().optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
@@ -309,6 +315,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
},
}),
client.position.findMany({
@@ -369,6 +378,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
showNotes: user.showNotes,
idleMinutes: user.idleMinutes,
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
sessionHours: user.sessionHours,
requireHiddenPassword: user.requireHiddenPassword,
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
},
currencies: [
...new Set([
@@ -717,8 +729,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
showNotes: b.preferences?.showNotes,
accountGroupOrder: b.preferences?.accountGroupOrder,
sessionHours: b.preferences?.sessionHours,
requireHiddenPassword: b.preferences?.requireHiddenPassword,
overviewCards: b.preferences?.overviewCards,
},
});
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
return { ok: true, positions: b.positions.length };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
+5 -3
View File
@@ -24,7 +24,7 @@ import { UserRequest } from './auth';
export const iconName = z.string().trim().min(1).max(100);
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
export async function normalizeIcon(data: Buffer) {
export async function normalizeIcon(data: Buffer, preserveWhite = false) {
if (!data.length || data.length > 2 * 1024 * 1024)
throw new BadRequestException('图标不能超过 2 MB');
try {
@@ -43,7 +43,7 @@ export async function normalizeIcon(data: Buffer) {
for (let i = 0; i < pixels.length; i += 4) {
const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
if (low >= 245 && high - low <= 8) pixels[i + 3] = 0;
if (!preserveWhite && low >= 245 && high - low <= 8) pixels[i + 3] = 0;
}
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
.png()
@@ -106,7 +106,9 @@ export class IconsController {
if (!icon) throw new NotFoundException('图标不存在');
res.setHeader('Content-Type', 'image/png');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(await normalizeIcon(Buffer.from(icon.data)));
// Uploads, built-in seeding and imports already validate stored PNG data.
// Preserve essential white artwork rather than applying the cutout twice.
res.send(Buffer.from(icon.data));
}
@Post('upload')
@UseInterceptors(
+4 -10
View File
@@ -12,6 +12,7 @@ import {
currency,
hiddenMenus,
accountGroupOrder,
settingsInput,
} from './validation';
export function setupOpenApi(app: INestApplication) {
const document = SwaggerModule.createDocument(
@@ -30,7 +31,7 @@ export function setupOpenApi(app: INestApplication) {
'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/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
'POST /api/positions/{id}/revisions': revisionInput,
@@ -41,16 +42,9 @@ export function setupOpenApi(app: INestApplication) {
'POST /api/transfers': transferInput,
'PUT /api/transfers/{id}': transferInput,
'POST /api/schedules': scheduleInput,
'PUT /api/schedules/{id}': scheduleInput,
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': z
.object({
baseCurrency: currency.optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440).optional(),
})
.strict(),
'PATCH /api/settings': settingsInput,
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
'POST /api/backup/import-file': z.object({
token: z.string().uuid(),
+37 -17
View File
@@ -3,6 +3,7 @@ import {
Controller,
Get,
Patch,
Res,
Post,
Req,
Body,
@@ -12,8 +13,16 @@ import {
BadGatewayException,
} from '@nestjs/common';
import { Database } from './database';
import { UserRequest } from './auth';
import { currency, date, rateValue, today, hiddenMenus, accountGroupOrder } from './validation';
import { AuthService, UserRequest } from './auth';
import { Response } from 'express';
import {
currency,
date,
rateValue,
today,
settingsInput,
defaultOverviewCards,
} from './validation';
import { z } from 'zod';
import Decimal from 'decimal.js';
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
@@ -165,6 +174,7 @@ export class SettingsController {
constructor(
private db: Database,
private fx: RatesService,
private auth: AuthService,
) {}
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
@@ -177,11 +187,17 @@ export class SettingsController {
showNotes: true,
idleMinutes: true,
accountGroupOrder: true,
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
},
});
return {
...u,
accountGroupOrder: u.accountGroupOrder || [],
overviewCards: u.overviewCards ?? [...defaultOverviewCards],
sessionExpiresAt: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
.expiresAt,
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
.lastActivity,
@@ -200,22 +216,26 @@ export class SettingsController {
: [],
};
}
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
const data = z
.object({
baseCurrency: currency.optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: 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: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
@Patch('settings') async update(
@Req() r: UserRequest,
@Body() b: unknown,
@Res({ passthrough: true }) res: Response,
) {
const data = settingsInput.parse(b);
const expiresAt =
data.sessionHours === undefined
? undefined
: new Date(Date.now() + data.sessionHours * 3600000);
await this.db.serial(async (tx) => {
await tx.user.update({
where: { id: r.userId },
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
});
if (data.requireHiddenPassword !== undefined)
await tx.session.updateMany({ where: { userId: r.userId }, data: { revealUntil: null } });
if (expiresAt) await tx.session.update({ where: { id: r.sessionId }, data: { expiresAt } });
});
if (expiresAt) this.auth.cookie(r.cookies.wp_session, expiresAt, res);
this.fx.invalidate(r.userId);
return { ok: true };
}
+56 -23
View File
@@ -3,6 +3,7 @@ import {
Get,
Post,
Patch,
Put,
Delete,
Body,
Param,
@@ -86,35 +87,67 @@ export class SchedulesController {
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
const v = scheduleInput.parse(body);
return this.db.serial(async (tx) => {
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
);
const accounts = await tx.position.findMany({
where: {
userId: r.userId,
id: { in: ids },
kind: 'account',
side: 'asset',
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
if (v.operation === 'expense' && v.targetId)
throw new BadRequestException('支出计划无需转入账户');
if (
v.operation === 'transfer' &&
accounts[0].currency === accounts[1].currency &&
!new Decimal(v.amount).eq(v.received)
)
throw new BadRequestException('同币种转出与到账金额必须一致');
await this.validateAccounts(tx, r, v);
return tx.schedule.create({
data: { ...v, userId: r.userId, nextAt: new Date(v.nextAt + ':00+08:00') },
select: { id: true },
});
});
}
private async validateAccounts(
tx: Prisma.TransactionClient,
r: UserRequest,
v: z.infer<typeof scheduleInput>,
) {
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
);
const accounts = await tx.position.findMany({
where: {
userId: r.userId,
id: { in: ids },
kind: 'account',
side: 'asset',
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
if (v.operation === 'expense' && v.targetId)
throw new BadRequestException('支出计划无需转入账户');
if (
v.operation === 'transfer' &&
accounts[0].currency === accounts[1].currency &&
!new Decimal(v.amount).eq(v.received)
)
throw new BadRequestException('同币种转出与到账金额必须一致');
}
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
const v = scheduleInput.parse(body);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Schedule WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const ids = await this.visible(r, tx);
const row = await tx.schedule.findFirst({
where: {
id,
userId: r.userId,
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
});
if (!row) throw new NotFoundException('计划不存在');
if (row.completed) throw new BadRequestException('一次性计划已完成,请新建计划');
await this.validateAccounts(tx, r, v);
await tx.schedule.update({
where: { id },
data: { ...v, nextAt: new Date(v.nextAt + ':00+08:00') },
});
return { ok: true };
});
}
@Patch(':id') async toggle(
@Req() r: UserRequest,
@Param('id') id: string,
+28
View File
@@ -187,3 +187,31 @@ export const accountGroupOrder = z
.array(z.string().trim().max(60))
.max(1000)
.refine((v) => new Set(v).size === v.length, '分组不可重复');
export const defaultOverviewCards = [
'assets',
'liabilities',
'net',
'trend',
'composition',
'recent',
'attribution',
] as const;
export const overviewCards = z
.array(z.enum(defaultOverviewCards))
.max(7)
.refine((v) => new Set(v).size === v.length, '卡片不可重复');
export const sessionHours = z.number().int().min(1).max(720);
export const settingsInput = z
.object({
baseCurrency: currency.optional(),
hiddenMenus: hiddenMenus.optional(),
accountGroupOrder: accountGroupOrder.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440).optional(),
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
})
.strict()
.refine((v) => Object.keys(v).length > 0);
+4 -1
View File
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
import { BadRequestException } from '@nestjs/common';
import { z } from 'zod';
import type { Backup } from './backup';
import { accountGroupOrder } from './validation';
import { accountGroupOrder, sessionHours, overviewCards } from './validation';
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
const files = [
@@ -151,6 +151,9 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
showSidebar: z.boolean().optional(),
hiddenMenus: z.array(z.string()).optional(),
accountGroupOrder: accountGroupOrder.optional(),
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})