feat: persist account group display order and backup preferences
This commit is contained in:
1 parent
20643b366f
commit
1af5edbadc
28 files changed
+379
-47
No files matched your search
@@ -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: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:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `User` ADD COLUMN `accountGroupOrder` JSON NULL COMMENT '账户分组显示顺序;空值表示沿用默认顺序';
|
||||
@@ -17,6 +17,8 @@ model User {
|
||||
baseCurrency String @default("CNY") @db.Char(3)
|
||||
/// 隐藏菜单标识列表
|
||||
hiddenMenus String @default("") @db.VarChar(128)
|
||||
/// 账户分组显示顺序;空值表示沿用默认顺序
|
||||
accountGroupOrder Json?
|
||||
/// 是否显示备注
|
||||
showNotes Boolean @default(true)
|
||||
/// 无操作退出分钟数,0 表示关闭
|
||||
|
||||
@@ -170,11 +170,13 @@ export class AuthController {
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
},
|
||||
});
|
||||
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
||||
return {
|
||||
...user,
|
||||
accountGroupOrder: user.accountGroupOrder || [],
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
revealed: req.revealed,
|
||||
revealUntil: session.revealUntil,
|
||||
|
||||
+11
-1
@@ -35,6 +35,7 @@ import {
|
||||
revisionInput,
|
||||
rateInput,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
transferInput,
|
||||
} from './validation';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -77,6 +78,7 @@ const backupSchema = z
|
||||
.object({
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
@@ -301,7 +303,13 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
const [user, ps, rates] = await Promise.all([
|
||||
client.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true },
|
||||
select: {
|
||||
baseCurrency: true,
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
},
|
||||
}),
|
||||
client.position.findMany({
|
||||
where: { userId },
|
||||
@@ -360,6 +368,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
showNotes: user.showNotes,
|
||||
idleMinutes: user.idleMinutes,
|
||||
accountGroupOrder: accountGroupOrder.parse(user.accountGroupOrder || []),
|
||||
},
|
||||
currencies: [
|
||||
...new Set([
|
||||
@@ -707,6 +716,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
idleMinutes: b.preferences?.idleMinutes,
|
||||
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
||||
showNotes: b.preferences?.showNotes,
|
||||
accountGroupOrder: b.preferences?.accountGroupOrder,
|
||||
},
|
||||
});
|
||||
return { ok: true, positions: b.positions.length };
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
transferInput,
|
||||
currency,
|
||||
hiddenMenus,
|
||||
accountGroupOrder,
|
||||
} from './validation';
|
||||
export function setupOpenApi(app: INestApplication) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
@@ -45,6 +46,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { currency, date, rateValue, today, hiddenMenus } from './validation';
|
||||
import { currency, date, rateValue, today, hiddenMenus, accountGroupOrder } from './validation';
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||
@@ -176,10 +176,12 @@ export class SettingsController {
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
accountGroupOrder: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
accountGroupOrder: u.accountGroupOrder || [],
|
||||
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
|
||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.lastActivity,
|
||||
@@ -203,6 +205,7 @@ export class SettingsController {
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
|
||||
@@ -182,3 +182,8 @@ export const credentialChange = z
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码');
|
||||
|
||||
export const accountGroupOrder = z
|
||||
.array(z.string().trim().max(60))
|
||||
.max(1000)
|
||||
.refine((v) => new Set(v).size === v.length, '分组不可重复');
|
||||
@@ -4,6 +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';
|
||||
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024;
|
||||
const files = [
|
||||
@@ -149,6 +150,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
.object({
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: z.array(z.string()).optional(),
|
||||
accountGroupOrder: accountGroupOrder.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
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('group order persists per user, validates input and survives ZIP and legacy imports', async () => {
|
||||
const db = new PrismaClient(),
|
||||
ids: string[] = [];
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
async function user() {
|
||||
const u = await db.user.create({
|
||||
data: { username: 'wp_groups_' + randomUUID(), passwordHash: 'unused' },
|
||||
});
|
||||
ids.push(u.id);
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(token).digest('hex'),
|
||||
userId: u.id,
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
return { id: u.id, cookie: 'wp_session=' + token };
|
||||
}
|
||||
async function call(path: string, cookie: string, 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() };
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user(),
|
||||
c = await user();
|
||||
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, []);
|
||||
const order = ['理财', '', '未分组', '日常'];
|
||||
assert.equal(
|
||||
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: order })).status,
|
||||
200,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await db.user.findUniqueOrThrow({ where: { id: a.id } })).accountGroupOrder,
|
||||
order,
|
||||
);
|
||||
assert.deepEqual((await call('/auth/me', a.cookie)).data.accountGroupOrder, order);
|
||||
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
|
||||
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, []);
|
||||
for (const invalid of [['日常', '日常'], [' 日常 ', '日常'], [42], ['x'.repeat(61)]]) {
|
||||
assert.equal(
|
||||
(await call('/settings', a.cookie, 'PATCH', { accountGroupOrder: invalid })).status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assert.deepEqual((await call('/settings', a.cookie)).data.accountGroupOrder, order);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions', a.cookie, 'POST', {
|
||||
name: 'Daily',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'cash',
|
||||
groupName: '日常',
|
||||
currency: 'CNY',
|
||||
amount: '20',
|
||||
date: '2026-09-01T10:00',
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
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.accountGroupOrder, order);
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
assert.deepEqual((await call('/auth/me', b.cookie)).data.accountGroupOrder, order);
|
||||
await call('/settings', b.cookie, 'PATCH', { accountGroupOrder: ['日常'] });
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
409,
|
||||
);
|
||||
assert.deepEqual((await call('/settings', b.cookie)).data.accountGroupOrder, ['日常']);
|
||||
delete backup.preferences.accountGroupOrder;
|
||||
assert.equal(
|
||||
(await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
assert.deepEqual((await call('/auth/me', c.cookie)).data.accountGroupOrder, []);
|
||||
const comments: any[] = await db.$queryRawUnsafe(
|
||||
"SELECT COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'User' AND COLUMN_NAME = 'accountGroupOrder'",
|
||||
);
|
||||
assert.ok(comments[0].comment.includes('分组'));
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: ids } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -120,7 +120,7 @@ test('negative balances are confined to accounts; group names and debt principal
|
||||
category: 'bank',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
amount: '-12.12345678',
|
||||
amount: '-12.24681357',
|
||||
currency: 'CNY',
|
||||
date,
|
||||
groupName: '日常',
|
||||
@@ -159,9 +159,9 @@ test('trend starts at the first balance, retains valid zero days and handles an
|
||||
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
|
||||
});
|
||||
test('debt signs and currency symbols preserve the original decimal strings', () => {
|
||||
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
|
||||
assert.equal(positionAmount(p), '-9999999999999999.12345678');
|
||||
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
|
||||
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.24681357' } as Position;
|
||||
assert.equal(positionAmount(p), '-9999999999999999.24681357');
|
||||
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.24681357');
|
||||
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
|
||||
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
|
||||
});
|
||||
@@ -348,14 +348,14 @@ test('liability account credit balances invert display and input without losing
|
||||
const card = {
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
amount: '-9999999999999999.12345678',
|
||||
amount: '-9999999999999999.24681357',
|
||||
} as Position;
|
||||
assert.equal(positionAmount(card), '9999999999999999.12345678');
|
||||
assert.equal(positionAmount(card, '12.12345678'), '-12.12345678');
|
||||
assert.equal(positionAmount(card), '9999999999999999.24681357');
|
||||
assert.equal(positionAmount(card, '12.24681357'), '-12.24681357');
|
||||
assert.equal(positionAmount(card, '-0.00000000'), '0.00000000');
|
||||
assert.equal(accountInputAmount(card, '12.12345678'), '-12.12345678');
|
||||
assert.equal(accountInputAmount(card, '-12.12345678'), '12.12345678');
|
||||
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.12345678'), '12.12345678');
|
||||
assert.equal(accountInputAmount(card, '12.24681357'), '-12.24681357');
|
||||
assert.equal(accountInputAmount(card, '-12.24681357'), '12.24681357');
|
||||
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.24681357'), '12.24681357');
|
||||
});
|
||||
test('credit balances count as assets across zero, retain FX attribution and do not offset other liabilities', () => {
|
||||
const card: Holding = {
|
||||
@@ -422,3 +422,13 @@ test('credit balances count as assets across zero, retain FX attribution and do
|
||||
assert.equal(cashflowDelta('liability', 'balance', '100', '-25').toFixed(), '125');
|
||||
assert.equal(cashflowDelta('liability', 'balance', '-25', '10').toFixed(), '-35');
|
||||
});
|
||||
|
||||
test('group order keeps visible subsets stable and distinguishes unnamed and named groups', async () => {
|
||||
const { orderedGroups, mergeGroupOrder } = await import('../../web/src/group-order');
|
||||
assert.deepEqual(
|
||||
orderedGroups(['日常', '', '未分组', '理财', '日常'], ['理财', '', '日常', '已隐藏']),
|
||||
['理财', '', '日常', '未分组'],
|
||||
);
|
||||
assert.deepEqual(orderedGroups(['日常'], ['理财', '', '日常']), ['日常']);
|
||||
assert.deepEqual(mergeGroupOrder(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']);
|
||||
});
|
||||
@@ -190,3 +190,12 @@ test('ZIP v7 restores groups and schedules while v6 remains readable', async ()
|
||||
contents['manifest.json'] = JSON.stringify(manifest);
|
||||
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
|
||||
});
|
||||
|
||||
test('ZIP settings retain group order while missing legacy order remains optional', async () => {
|
||||
const b = empty();
|
||||
b.preferences!.accountGroupOrder = ['理财', '', '未分组', '日常'];
|
||||
const restored = validateBackup(await readBackupZip(await archive(packBackup(b))));
|
||||
assert.deepEqual(restored.preferences!.accountGroupOrder, b.preferences!.accountGroupOrder);
|
||||
const legacy = validateBackup(await readBackupZip(await archive(packBackup(empty()))));
|
||||
assert.equal(legacy.preferences!.accountGroupOrder, undefined);
|
||||
});
|
||||
Reference in new issue
Block a user