diff --git a/README.md b/README.md index 84636e4..cb41db0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的 账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。 -预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。 +预置银行、支付平台与交易所等共 36 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。 ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 @@ -84,3 +84,5 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json “定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。 本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。 + +2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。 diff --git a/apps/api/assets/icons/20.png b/apps/api/assets/icons/20.png index 86a0a87..19214d2 100644 Binary files a/apps/api/assets/icons/20.png and b/apps/api/assets/icons/20.png differ diff --git a/apps/api/assets/icons/sources.json b/apps/api/assets/icons/sources.json index 51bb1eb..e6a33c3 100644 --- a/apps/api/assets/icons/sources.json +++ b/apps/api/assets/icons/sources.json @@ -115,9 +115,10 @@ }, { "name": "京东金融", - "source": "https://jr.jd.com/logo.png", + "source": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg", "file": "20.png", - "sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693" + "sha256": "9274fde6f793cf49f239fe3995cc959f33d71a02f93866e416ae5f5a533b05de", + "preserveWhite": true }, { "name": "汇丰香港", diff --git a/apps/api/package.json b/apps/api/package.json index f94f970..ef29ab7 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/security-backup.test.ts test/credit-balance.test.ts test/group-order.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/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts", "test:performance": "tsx scripts/performance.ts after", "icons:seed": "node scripts/seed-icons.cjs" }, diff --git a/apps/api/prisma/migrations/20261003000200_session_display_settings/migration.sql b/apps/api/prisma/migrations/20261003000200_session_display_settings/migration.sql new file mode 100644 index 0000000..cb3fccb --- /dev/null +++ b/apps/api/prisma/migrations/20261003000200_session_display_settings/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE User + ADD COLUMN sessionHours INTEGER NOT NULL DEFAULT 168 COMMENT '登录有效时长(小时),范围 1 至 720', + ADD COLUMN requireHiddenPassword BOOLEAN NOT NULL DEFAULT true COMMENT '查看隐藏项目是否需要再次验证密码', + ADD COLUMN overviewCards JSON NULL COMMENT '总览显示的卡片键列表,NULL 表示默认全部显示'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8f6923c..d4b7949 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -17,6 +17,12 @@ model User { baseCurrency String @default("CNY") @db.Char(3) /// 隐藏菜单标识列表 hiddenMenus String @default("") @db.VarChar(128) + /// 登录有效时长,单位小时,范围 1 至 720 + sessionHours Int @default(168) + /// 查看隐藏项目是否需要再次验证密码 + requireHiddenPassword Boolean @default(true) + /// 总览显示的卡片键列表,空值表示默认全部显示 + overviewCards Json? /// 账户分组显示顺序;空值表示沿用默认顺序 accountGroupOrder Json? /// 是否显示备注 diff --git a/apps/api/scripts/seed-icons.cjs b/apps/api/scripts/seed-icons.cjs index a76d9c7..fcf284a 100644 --- a/apps/api/scripts/seed-icons.cjs +++ b/apps/api/scripts/seed-icons.cjs @@ -11,7 +11,10 @@ async function main() { const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8')); // Update only deterministic built-in image IDs; preserve user uploads and financial data. for (const item of sources) { - const data = await normalizeIcon(await readFile(join(dir, item.file))); + const data = await normalizeIcon( + await readFile(join(dir, item.file)), + item.preserveWhite === true, + ); const hex = createHash('sha256') .update('worthpath-builtin:' + item.name) .digest('hex'); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 8b95002..ee454a1 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -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) { diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 22ade2d..9aca1d2 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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 }, diff --git a/apps/api/src/icons.ts b/apps/api/src/icons.ts index c1c6be5..be416dc 100644 --- a/apps/api/src/icons.ts +++ b/apps/api/src/icons.ts @@ -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( diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index ff7668f..5d537ee 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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(), diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 5e4c9ae..f601d91 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -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 }; } diff --git a/apps/api/src/schedules.ts b/apps/api/src/schedules.ts index f697e45..76e1c46 100644 --- a/apps/api/src/schedules.ts +++ b/apps/api/src/schedules.ts @@ -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, + ) { + 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, diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 104a567..7a0f89c 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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); diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index 948b56c..c3c141e 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -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 { 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), }) diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index dc29201..637811e 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -277,7 +277,10 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures const backup = (await call('/backup', 'GET', undefined, a.cookie)).data; assert.equal(backup.positions.length, 4); assert.equal(backup.links.length, 2); - assert.doesNotMatch(JSON.stringify(backup), /password|token|userId|session/i); + assert.doesNotMatch( + JSON.stringify(backup), + /"(?:password|passwordHash|token|userId|session|sessionId|sessions|cookie)"\s*:/i, + ); assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409); assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201); assert.equal( diff --git a/apps/api/test/settings-plans.test.ts b/apps/api/test/settings-plans.test.ts new file mode 100644 index 0000000..e16f7b0 --- /dev/null +++ b/apps/api/test/settings-plans.test.ts @@ -0,0 +1,245 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import { request } from 'node:http'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { hash } from 'bcryptjs'; +import { readBackupZip } from '../src/zip'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; +const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; +const password = 'Fixture-session-only-42!'; +const db = new PrismaClient(); +async function fixture() { + const u = await db.user.create({ + data: { + username: 'wp_settings_' + randomUUID(), + passwordHash: await hash(password, 4), + idleMinutes: 0, + }, + }); + const token = randomBytes(32).toString('hex'); + const id = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) }, + }); + return { ...u, sessionId: id, cookie: 'wp_session=' + token }; +} +// Give this fixture suite its own loopback source address so independent auth +// scenarios do not consume the existing suite's per-IP production rate limit. +async function call(path: string, cookie: string, method = 'GET', body?: unknown) { + const data = body === undefined ? undefined : JSON.stringify(body); + return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => { + const req = request( + new URL(base + path), + { + method, + localAddress: '127.0.0.2', + headers: { + Cookie: cookie, + Origin: origin, + ...(data === undefined + ? {} + : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => { + try { + resolve({ + status: res.statusCode!, + data: JSON.parse(Buffer.concat(chunks).toString()), + cookie: res.headers['set-cookie']?.[0] ?? null, + }); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on('error', reject); + req.end(data); + }); +} +test('session duration boundaries, privacy isolation and card settings survive backups', async () => { + const a = await fixture(), + b = await fixture(), + c = await fixture(); + try { + const initial = await call('/auth/me', a.cookie); + assert.equal(initial.data.sessionHours, 168); + assert.equal(initial.data.requireHiddenPassword, true); + assert.equal(initial.data.overviewCards.length, 7); + for (const sessionHours of [0, 721, 1.5, '24']) + assert.equal((await call('/settings', a.cookie, 'PATCH', { sessionHours })).status, 400); + for (const overviewCards of [['unknown'], ['net', 'net'], [1]]) + assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards })).status, 400); + assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400); + assert.equal( + (await call('/auth/reveal', a.cookie, 'POST', { password: 'Incorrect-password-42' })).status, + 403, + ); + assert.equal((await call('/auth/reveal', a.cookie, 'POST', { password })).status, 201); + const secondToken = randomBytes(32).toString('hex'); + const secondId = createHash('sha256').update(secondToken).digest('hex'); + const oldExpiry = new Date(Date.now() + 36000000); + await db.session.create({ + data: { + id: secondId, + userId: a.id, + expiresAt: oldExpiry, + revealUntil: new Date(Date.now() + 60000), + }, + }); + const result = await call('/settings', a.cookie, 'PATCH', { + sessionHours: 1, + requireHiddenPassword: false, + overviewCards: ['net', 'recent'], + }); + assert.equal(result.status, 200); + assert.match(result.cookie!, /HttpOnly/); + assert.match(result.cookie!, /Expires=/); + const current = await db.session.findUniqueOrThrow({ where: { id: a.sessionId } }); + assert.ok(Math.abs(+current.expiresAt - Date.now() - 3600000) < 5000); + assert.equal(current.revealUntil, null); + const other = await db.session.findUniqueOrThrow({ where: { id: secondId } }); + assert.ok(Math.abs(+other.expiresAt - +oldExpiry) < 1000); + assert.equal(other.revealUntil, null); + assert.equal((await call('/auth/me', b.cookie)).data.requireHiddenPassword, true); + assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 201); + assert.equal((await call('/auth/me', a.cookie)).data.revealed, true); + await call('/settings', a.cookie, 'PATCH', { requireHiddenPassword: true }); + assert.equal((await call('/auth/me', a.cookie)).data.revealed, false); + assert.equal((await call('/auth/reveal', a.cookie, 'POST', {})).status, 400); + await call('/settings', a.cookie, 'PATCH', { sessionHours: 720, requireHiddenPassword: false }); + const login = await call('/auth/login', '', 'POST', { username: a.username, password }); + assert.equal(login.status, 201); + const freshCookie = login.cookie!.split(';')[0]; + const me = (await call('/auth/me', freshCookie)).data; + assert.ok(Math.abs(+new Date(me.sessionExpiresAt) - Date.now() - 720 * 3600000) < 5000); + const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } }); + assert.equal(zip.status, 200); + const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer())); + assert.deepEqual(backup.preferences.overviewCards, ['net', 'recent']); + assert.equal(backup.preferences.sessionHours, 720); + assert.equal(backup.preferences.requireHiddenPassword, false); + assert.equal( + (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + 201, + ); + const imported = (await call('/settings', b.cookie)).data; + assert.equal(imported.sessionHours, 720); + assert.equal(imported.requireHiddenPassword, false); + assert.deepEqual(imported.overviewCards, ['net', 'recent']); + delete backup.preferences.sessionHours; + delete backup.preferences.requireHiddenPassword; + delete backup.preferences.overviewCards; + assert.equal( + (await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status, + 201, + ); + const legacy = (await call('/settings', c.cookie)).data; + assert.equal(legacy.sessionHours, 168); + assert.equal(legacy.requireHiddenPassword, true); + assert.equal(legacy.overviewCards.length, 7); + assert.equal((await call('/settings', a.cookie, 'PATCH', { overviewCards: [] })).status, 200); + assert.deepEqual((await call('/auth/me', a.cookie)).data.overviewCards, []); + await db.session.update({ + where: { id: a.sessionId }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + assert.equal((await call('/auth/me', a.cookie)).status, 401); + } finally { + await db.user.deleteMany({ where: { id: { in: [a.id, b.id, c.id] } } }); + } +}); +test('plan edits validate accounts, preserve history and reject hidden or foreign plans', async () => { + const a = await fixture(), + b = await fixture(); + try { + const position = async (name: string, hidden = false) => { + const r = await call('/positions', a.cookie, 'POST', { + name, + kind: 'account', + side: 'asset', + category: 'cash', + currency: 'CNY', + amount: '100', + date: '2026-09-01T10:00', + hidden, + }); + assert.equal(r.status, 201); + return r.data.id; + }; + const source = await position('Source'), + target = await position('Target'), + hidden = await position('Hidden', true); + const payload = { + name: 'Original', + operation: 'expense', + sourceId: source, + targetId: null, + amount: '10', + received: '0', + nextAt: '2026-10-01T10:00', + intervalDays: 30, + notes: 'initial', + }; + const created = await call('/schedules', a.cookie, 'POST', payload); + assert.equal(created.status, 201); + const id = created.data.id; + const run = await call('/schedules/run', a.cookie, 'POST', {}); + assert.equal(run.data.executed, 1); + const records = await db.revision.findMany({ + where: { positionId: source }, + orderBy: { sequence: 'asc' }, + }); + const updated = { + ...payload, + name: 'Edited transfer', + operation: 'transfer', + targetId: target, + amount: '20.00000001', + received: '20.00000001', + nextAt: '2027-01-01T09:00', + intervalDays: 7, + notes: 'new memo', + }; + assert.equal((await call('/schedules/' + id, b.cookie, 'PUT', updated)).status, 404); + assert.equal( + (await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, targetId: hidden })).status, + 400, + ); + assert.equal( + (await call('/schedules/' + id, a.cookie, 'PUT', { ...updated, received: '21' })).status, + 400, + ); + assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 200); + const plans = (await call('/schedules', a.cookie)).data; + const plan = plans.find((p: any) => p.id === id); + assert.equal(plan.name, updated.name); + assert.equal(plan.nextAt, updated.nextAt); + assert.equal(plan.received, updated.received); + assert.equal(plan.notes, updated.notes); + assert.equal(plan.enabled, true); + assert.deepEqual( + await db.revision.findMany({ where: { positionId: source }, orderBy: { sequence: 'asc' } }), + records, + ); + const done = await call('/schedules', a.cookie, 'POST', { + ...payload, + name: 'Once', + intervalDays: 0, + }); + await call('/schedules/run', a.cookie, 'POST', {}); + assert.equal((await call('/schedules/' + done.data.id, a.cookie, 'PUT', updated)).status, 400); + await db.position.update({ where: { id: source }, data: { hidden: true } }); + assert.equal((await call('/schedules/' + id, a.cookie, 'PUT', updated)).status, 404); + } finally { + await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index dc89a6e..4d1943c 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -285,6 +285,11 @@ test('icon processing creates transparent white cutouts and preserves brand colo .ensureAlpha() .raw() .toBuffer(); + const preserved = await sharp(await normalizeIcon(input, true)) + .ensureAlpha() + .raw() + .toBuffer(); + assert.deepEqual([...preserved], [...pixels]); assert.equal(output[3], 0); assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2b3a8c6..2122eca 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -46,12 +46,15 @@ import { type PageResult, } from './api'; import './style.css'; +import { allOverviewCards, overviewCardLabels } from './overview-cards'; +import { useToast } from './Toast'; import { GroupOrderContext } from './GroupOrderContext'; import { GroupOrderList } from './GroupOrderList'; import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { IconPicker } from './IconPicker'; +import { QuickTransfer } from './QuickTransfer'; import { TransferForm } from './TransferForm'; import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; import type { Transfer } from './api'; @@ -243,8 +246,42 @@ export default function App() { [error, setError] = useState(''), [success, setSuccess] = useState(''); const [calendarRefresh, setCalendarRefresh] = useState(0); + const [quickTransfer, setQuickTransfer] = useState<{ + sourceId?: string; + targetId?: string; + } | null>(null); const [quickMode, setQuickMode] = useState(false), [accountGroup, setAccountGroup] = useState(null); + useEffect(() => { + setQuickTransfer(null); + }, [page, quickMode, selected, user?.revealed]); + useEffect(() => { + setQuickTransfer((current) => + current && + [current.sourceId, current.targetId].some( + (id) => + id && + !positions.some( + (p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived, + ), + ) + ? null + : current, + ); + }, [positions]); + function chooseQuickAccount(p: Position) { + if (busy || loading) return; + if (p.kind !== 'account' || p.side !== 'asset' || p.archived) { + setError(tr('请选择启用的资产账户')); + return; + } + setQuickTransfer((current) => { + if (!current) return current; + if (!current.sourceId) return { sourceId: p.id }; + if (!current.targetId && p.id !== current.sourceId) return { ...current, targetId: p.id }; + return current; + }); + } const [fxStatus, setFxStatus] = useState<{ state: string; message: string; @@ -261,6 +298,8 @@ export default function App() { } | null>(null); const [clearStep, setClearStep] = useState(0), [clearConfirmation, setClearConfirmation] = useState(0); + useToast(error, 'error'); + useToast(success, 'success'); const [settingsSection, setSettingsSection] = useState('general'), [transfers, setTransfers] = useState([]); const [historyRows, setHistoryRows] = useState([]), @@ -305,6 +344,7 @@ export default function App() { setRegister(false); setLoading(false); setSuccess(''); + setQuickTransfer(null); } const report = (e: unknown) => { setError(e instanceof Error ? e.message : tr('操作失败')); @@ -714,11 +754,7 @@ export default function App() {

{register ? tr('建立属于你的私人资产空间') : tr('登录以查看你的资产与负债')}

- {error && ( -
- {tr(error)} -
- )} +
{user.revealed - ? tr('当前包含隐藏项目 · 验证 5 分钟后自动锁定') + ? tr('当前包含隐藏资产 · 5 分钟后自动锁定') : tr('当前不包含隐藏账户、资产和债务,净资产按此范围计算')}
@@ -947,28 +989,13 @@ export default function App() {
{page === 'account' && !p && ( )} {['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction}
- {error && ( -
- {error} - -
- )} - {success && ( -
- {tr(success)} - -
- )} + {loading && (

{tr('正在刷新数据…')} @@ -976,6 +1003,20 @@ export default function App() { )} {page === 'overview' && overview && ( <> + {user.overviewCards?.length === 0 && ( +

+

{tr('尚未选择总览卡片')}

+ +
+ )} {!overview.complete && (
{tr('缺少')} @@ -986,39 +1027,69 @@ export default function App() {
)} -
+ -
-
+
+ (user.overviewCards ?? allOverviewCards).includes(key), + ) + ? ' single-card' + : '') + } + hidden={ + !['trend', 'composition'].some((key) => + (user.overviewCards ?? allOverviewCards).includes(key), + ) + } + > + -
+
-
+
+ + )} )} {['account', 'asset', 'debt'].includes(page) && @@ -1188,8 +1266,8 @@ export default function App() {

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

- {p.hidden && {tr('隐藏项目')}} -

+ {p.hidden && {tr('隐藏资产')}} +

- {quickMode &&

{tr('点击账户卡片,直接填写当前余额。')}

} + {quickMode && ( +

+ {tr( + quickTransfer !== null + ? '快速转账中:依次点击转出和转入账户卡片。' + : '点击账户卡片,直接填写当前余额。', + )} +

+ )} )} {positions.some((p) => p.kind === page) ? ( -
+
{positions .filter( (p) => @@ -1375,12 +1466,29 @@ export default function App() {

{p.groupName || tr('未分组')}

)}