feat: add account transfers and refine icons, settings and safety dialogs
This commit is contained in:
1 parent
a4e9d56b8a
commit
da98d02661
33 files changed
+1866
-347
No files matched your search
@@ -156,10 +156,17 @@ export class AuthController {
|
||||
return { username: user.username, baseCurrency: user.baseCurrency };
|
||||
}
|
||||
@Get('auth/me') async me(@Req() req: UserRequest) {
|
||||
return this.db.user.findUniqueOrThrow({
|
||||
const user = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: req.userId },
|
||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
select: {
|
||||
username: true,
|
||||
baseCurrency: true,
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
},
|
||||
});
|
||||
return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) };
|
||||
}
|
||||
@Post('auth/activity') async activity(@Req() r: UserRequest) {
|
||||
await this.db.session.update({
|
||||
|
||||
+134
-6
@@ -25,7 +25,15 @@ import { Prisma } from '@prisma/client';
|
||||
import Decimal from 'decimal.js';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { positionInput, positionMeta, currency, revisionInput, rateInput } from './validation';
|
||||
import {
|
||||
positionInput,
|
||||
positionMeta,
|
||||
currency,
|
||||
revisionInput,
|
||||
rateInput,
|
||||
hiddenMenus,
|
||||
transferInput,
|
||||
} from './validation';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from './validation';
|
||||
import { iconName, validateStoredIcon } from './icons';
|
||||
@@ -65,7 +73,12 @@ const backupSchema = z
|
||||
baseCurrency: currency,
|
||||
currencies: z.array(currency).max(10),
|
||||
preferences: z
|
||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
||||
.object({
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
icons: z
|
||||
@@ -81,6 +94,19 @@ const backupSchema = z
|
||||
.strict(),
|
||||
)
|
||||
.optional(),
|
||||
transfers: z
|
||||
.array(
|
||||
transferInput.safeExtend({
|
||||
id: z.string().uuid(),
|
||||
importedFromId: z.string().uuid().nullable().optional(),
|
||||
sourceRevisionId: z.string().uuid(),
|
||||
targetRevisionId: z.string().uuid(),
|
||||
sourceCurrency: currency,
|
||||
targetCurrency: currency,
|
||||
createdAt: timestamp,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
positions: z.array(record),
|
||||
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
||||
@@ -122,6 +148,57 @@ export function validateBackup(raw: unknown) {
|
||||
}
|
||||
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
||||
}
|
||||
const transferIds = new Set<string>(),
|
||||
usedRevisions = new Set<string>();
|
||||
for (const t of b.transfers || []) {
|
||||
const source = ids.get(t.sourceId),
|
||||
target = ids.get(t.targetId);
|
||||
const origin = t.importedFromId || t.id;
|
||||
if (
|
||||
transferIds.has(origin) ||
|
||||
!source ||
|
||||
!target ||
|
||||
source.kind !== 'account' ||
|
||||
target.kind !== 'account' ||
|
||||
source.side !== 'asset' ||
|
||||
target.side !== 'asset' ||
|
||||
source.currency !== t.sourceCurrency ||
|
||||
target.currency !== t.targetCurrency
|
||||
)
|
||||
throw new BadRequestException('转账关联无效');
|
||||
transferIds.add(origin);
|
||||
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
||||
throw new BadRequestException('同币种转账金额不一致');
|
||||
for (const [p, revId, reason, delta] of [
|
||||
[source, t.sourceRevisionId, 'transfer_out', new Decimal(t.amount).plus(t.fee).neg()],
|
||||
[target, t.targetRevisionId, 'transfer_in', new Decimal(t.received)],
|
||||
] as const) {
|
||||
const ordered = [...p.revisions].sort(
|
||||
(a, b) =>
|
||||
a.date.localeCompare(b.date) ||
|
||||
(a.sequence || 0) - (b.sequence || 0) ||
|
||||
a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
const index = ordered.findIndex((r) => r.id === revId),
|
||||
current = ordered[index];
|
||||
if (
|
||||
usedRevisions.has(revId) ||
|
||||
index < 1 ||
|
||||
!current ||
|
||||
current.reason !== reason ||
|
||||
current.date !== t.date ||
|
||||
!new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta)
|
||||
)
|
||||
throw new BadRequestException('转账历史与双方金额不一致');
|
||||
usedRevisions.add(revId);
|
||||
}
|
||||
}
|
||||
if (
|
||||
b.positions.some((p) =>
|
||||
p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)),
|
||||
)
|
||||
)
|
||||
throw new BadRequestException('缺少配对转账记录');
|
||||
const links = new Set<string>();
|
||||
for (const l of b.links) {
|
||||
const s = ids.get(l.sourceId),
|
||||
@@ -187,7 +264,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
const [user, ps, rates] = await Promise.all([
|
||||
client.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
select: { baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true },
|
||||
}),
|
||||
client.position.findMany({
|
||||
where: { userId },
|
||||
@@ -234,12 +311,17 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
],
|
||||
},
|
||||
});
|
||||
const transfers = await client.transfer.findMany({ where: { userId } });
|
||||
return backupSchema.parse({
|
||||
format: 'worthpath',
|
||||
version: 2,
|
||||
exportedAt: new Date().toISOString(),
|
||||
baseCurrency: user.baseCurrency,
|
||||
preferences: { showSidebar: user.showSidebar, idleMinutes: user.idleMinutes },
|
||||
preferences: {
|
||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||
showNotes: user.showNotes,
|
||||
idleMinutes: user.idleMinutes,
|
||||
},
|
||||
currencies: [
|
||||
...new Set([
|
||||
user.baseCurrency,
|
||||
@@ -254,6 +336,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
hash: i.hash,
|
||||
image: Buffer.from(i.data).toString('base64'),
|
||||
})),
|
||||
transfers: transfers.map(({ userId, effectiveDate, ...t }) => ({
|
||||
...t,
|
||||
amount: t.amount.toString(),
|
||||
received: t.received.toString(),
|
||||
fee: t.fee.toString(),
|
||||
date: businessTime(effectiveDate),
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
})),
|
||||
positions,
|
||||
links: ps.flatMap((p) =>
|
||||
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
||||
@@ -353,6 +443,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
||||
);
|
||||
data.currencies.sort();
|
||||
data.transfers?.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');
|
||||
}
|
||||
@@ -393,6 +484,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0),
|
||||
rates: b.rates.length,
|
||||
icons: (b.icons || []).length,
|
||||
transfers: (b.transfers || []).length,
|
||||
baseCurrency: b.baseCurrency,
|
||||
currentBaseCurrency: existing.baseCurrency,
|
||||
message:
|
||||
@@ -458,7 +550,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
});
|
||||
iconMapping.set(i.id, row.id);
|
||||
}
|
||||
const mapping = new Map<string, string>();
|
||||
const mapping = new Map<string, string>(),
|
||||
revisionMapping = new Map<string, string>();
|
||||
for (const p of b.positions) {
|
||||
const row = await tx.position.create({
|
||||
data: {
|
||||
@@ -495,7 +588,37 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
},
|
||||
});
|
||||
mapping.set(p.id, row.id);
|
||||
const originals = [...p.revisions].sort(
|
||||
(a, b) =>
|
||||
a.date.localeCompare(b.date) ||
|
||||
(a.sequence || 0) - (b.sequence || 0) ||
|
||||
a.createdAt.localeCompare(b.createdAt),
|
||||
);
|
||||
const restored = await tx.revision.findMany({
|
||||
where: { positionId: row.id },
|
||||
orderBy: { sequence: 'asc' },
|
||||
});
|
||||
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
|
||||
}
|
||||
for (const t of b.transfers || [])
|
||||
await tx.transfer.create({
|
||||
data: {
|
||||
userId: r.userId,
|
||||
importedFromId: t.importedFromId || t.id,
|
||||
sourceId: mapping.get(t.sourceId)!,
|
||||
targetId: mapping.get(t.targetId)!,
|
||||
sourceRevisionId: revisionMapping.get(t.sourceRevisionId)!,
|
||||
targetRevisionId: revisionMapping.get(t.targetRevisionId)!,
|
||||
sourceCurrency: t.sourceCurrency,
|
||||
targetCurrency: t.targetCurrency,
|
||||
amount: t.amount,
|
||||
received: t.received,
|
||||
fee: t.fee,
|
||||
effectiveDate: toBusinessDate(t.date),
|
||||
notes: t.notes,
|
||||
createdAt: new Date(t.createdAt),
|
||||
},
|
||||
});
|
||||
for (const l of b.links)
|
||||
await tx.positionLink.create({
|
||||
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
|
||||
@@ -516,7 +639,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
if (!ps.length && !rs.length)
|
||||
await tx.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { baseCurrency: b.baseCurrency, ...b.preferences },
|
||||
data: {
|
||||
baseCurrency: b.baseCurrency,
|
||||
idleMinutes: b.preferences?.idleMinutes,
|
||||
hiddenMenus: b.preferences?.hiddenMenus?.join(','),
|
||||
showNotes: b.preferences?.showNotes,
|
||||
},
|
||||
});
|
||||
return { ok: true, positions: b.positions.length };
|
||||
},
|
||||
|
||||
+13
-2
@@ -32,9 +32,20 @@ export async function normalizeIcon(data: Buffer) {
|
||||
const meta = await image.metadata();
|
||||
if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1)
|
||||
throw Error();
|
||||
return await image
|
||||
const resized = await image
|
||||
.rotate()
|
||||
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
// Neutral white logo backgrounds become transparent; coloured marks remain unchanged.
|
||||
const { data: pixels, info } = resized;
|
||||
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;
|
||||
}
|
||||
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
} catch {
|
||||
@@ -95,7 +106,7 @@ export class IconsController {
|
||||
if (!icon) throw new NotFoundException('图标不存在');
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.send(Buffer.from(icon.data));
|
||||
res.send(await normalizeIcon(Buffer.from(icon.data)));
|
||||
}
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
import { setupOpenApi } from './openapi';
|
||||
import { Module, Catch, ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import { NestFactory, APP_GUARD } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import helmet from 'helmet';
|
||||
import { json } from 'express';
|
||||
import { AuthController, AuthGuard, AuthService } from './auth';
|
||||
import { TransfersController } from './transfers';
|
||||
import { PortfolioController } from './portfolio';
|
||||
import { BackupController } from './backup';
|
||||
import { IconsController, IconsService } from './icons';
|
||||
@@ -50,6 +52,7 @@ class SafeErrors implements ExceptionFilter {
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
],
|
||||
controllers: [
|
||||
TransfersController,
|
||||
IconsController,
|
||||
AuthController,
|
||||
PortfolioController,
|
||||
@@ -74,6 +77,7 @@ async function bootstrap() {
|
||||
next();
|
||||
});
|
||||
app.useGlobalFilters(new SafeErrors());
|
||||
setupOpenApi(app);
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(Number(process.env.PORT || 3100), '0.0.0.0');
|
||||
console.log('WorthPath API ready');
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
credentials,
|
||||
positionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
transferInput,
|
||||
currency,
|
||||
hiddenMenus,
|
||||
} from './validation';
|
||||
export function setupOpenApi(app: INestApplication) {
|
||||
const document = SwaggerModule.createDocument(
|
||||
app,
|
||||
new DocumentBuilder()
|
||||
.setTitle('WorthPath API')
|
||||
.setVersion('1.1')
|
||||
.setDescription(
|
||||
'个人资产管理接口。金额使用十进制字符串;登录会话由 HttpOnly Cookie 传递,数据归属由服务端验证。',
|
||||
)
|
||||
.addCookieAuth('wp_session', { type: 'apiKey', in: 'cookie' }, 'session')
|
||||
.addSecurityRequirements('session')
|
||||
.build(),
|
||||
);
|
||||
const bodies: Record<string, z.ZodType> = {
|
||||
'POST /api/auth/register': credentials,
|
||||
'POST /api/auth/login': credentials,
|
||||
'POST /api/auth/reveal': credentials.pick({ password: true }),
|
||||
'POST /api/positions': positionInput,
|
||||
'PATCH /api/positions/{id}': positionMeta,
|
||||
'POST /api/positions/{id}/revisions': revisionInput,
|
||||
'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput,
|
||||
'PUT /api/positions/{id}/links': z
|
||||
.object({ targetIds: z.array(z.string().uuid()).max(20) })
|
||||
.strict(),
|
||||
'POST /api/transfers': transferInput,
|
||||
'PATCH /api/settings': z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
hiddenMenus: hiddenMenus.optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440).optional(),
|
||||
})
|
||||
.strict(),
|
||||
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||
'POST /api/backup/import-file': z.object({
|
||||
token: z.string().uuid(),
|
||||
confirmed: z.literal(true),
|
||||
}),
|
||||
};
|
||||
for (const [path, entry] of Object.entries(document.paths)) {
|
||||
for (const method of ['get', 'post', 'patch', 'put'] as const) {
|
||||
const operation = entry[method];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path))
|
||||
operation.security = [];
|
||||
const schema = bodies[method.toUpperCase() + ' ' + path];
|
||||
if (schema)
|
||||
operation.requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.toJSONSchema(schema, { target: 'openapi-3.0' }) as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
operation.responses['400'] = {
|
||||
description: '请求格式、金额或业务校验失败,返回 { message }',
|
||||
};
|
||||
operation.responses['401'] = { description: '未登录或会话失效' };
|
||||
operation.responses['403'] = { description: '请求来源或访问权限不受信任' };
|
||||
operation.responses['409'] = { description: '数据冲突或业务记录不可修改' };
|
||||
if (path === '/api/backup' && method === 'get')
|
||||
operation.responses['200'] = {
|
||||
description: '该用户全部数据的 ZIP 文件',
|
||||
content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } },
|
||||
};
|
||||
if (path === '/api/backup/upload' || path === '/api/icons/upload') {
|
||||
const properties = {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
...(path.includes('icons')
|
||||
? {
|
||||
name: { type: 'string', description: '共享图标必须包含中文' },
|
||||
shared: { type: 'string', enum: ['false', 'true'], default: 'false' },
|
||||
confirmed: { type: 'string', enum: ['true'], description: '共享发布必须确认' },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
operation.requestBody = {
|
||||
required: true,
|
||||
content: {
|
||||
'multipart/form-data': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: path.includes('icons') ? ['file', 'name'] : ['file'],
|
||||
properties: properties as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (['/api/backup/preview', '/api/backup/import'].includes(path))
|
||||
operation.requestBody = {
|
||||
required: true,
|
||||
description: '旧版 JSON 兼容入口;新版请使用 upload + import-file(支持完整 ZIP 备份)',
|
||||
content: {
|
||||
'application/json': { schema: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
jsonDocumentUrl: 'api/openapi.json',
|
||||
swaggerOptions: { persistAuthorization: false, validatorUrl: null, withCredentials: true },
|
||||
});
|
||||
}
|
||||
+42
-14
@@ -102,6 +102,7 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b);
|
||||
if (v.reason.startsWith('transfer_')) throw new BadRequestException('请使用账户转账接口');
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
@@ -109,6 +110,12 @@ export class PortfolioController {
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const lastTransfer = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, reason: { in: ['transfer_out', 'transfer_in'] } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate)
|
||||
throw new ConflictException('余额调整时间不能早于已有转账;请使用当前时间调整');
|
||||
if (v.reason === 'repayment') {
|
||||
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
||||
const prior = await tx.revision.findFirst({
|
||||
@@ -137,22 +144,43 @@ export class PortfolioController {
|
||||
@Param('revisionId') revisionId: string,
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b),
|
||||
p = await this.own(r.userId, id, r.revealed);
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
if (!p.revisions.some((x) => x.id === revisionId))
|
||||
throw new NotFoundException('历史记录不存在');
|
||||
await this.db.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: 'correction',
|
||||
const v = revisionInput.parse(b);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: { revisions: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const original = p.revisions.find((h) => h.id === revisionId);
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
if (
|
||||
p.revisions.some(
|
||||
(h) =>
|
||||
h.reason.startsWith('transfer_') &&
|
||||
(+h.effectiveDate >= +original.effectiveDate ||
|
||||
toBusinessDate(v.date) < h.effectiveDate),
|
||||
)
|
||||
)
|
||||
throw new ConflictException(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
await tx.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: 'correction',
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
}
|
||||
|
||||
@Put('positions/:id/links') async link(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
|
||||
+15
-4
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { currency, date, rateValue, today } from './validation';
|
||||
import { currency, date, rateValue, today, hiddenMenus } from './validation';
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
// Fixed public request; no user currency choices, identifiers or amounts leave the server.
|
||||
@@ -168,10 +168,17 @@ export class SettingsController {
|
||||
@Get('settings') async settings(@Req() r: UserRequest) {
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true },
|
||||
select: {
|
||||
username: true,
|
||||
baseCurrency: true,
|
||||
hiddenMenus: true,
|
||||
showNotes: true,
|
||||
idleMinutes: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
hiddenMenus: u.hiddenMenus.split(',').filter(Boolean),
|
||||
lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } }))
|
||||
.lastActivity,
|
||||
revealed: r.revealed,
|
||||
@@ -190,13 +197,17 @@ export class SettingsController {
|
||||
const data = z
|
||||
.object({
|
||||
baseCurrency: currency.optional(),
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: hiddenMenus.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 });
|
||||
await this.db.user.update({
|
||||
where: { id: r.userId },
|
||||
data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') },
|
||||
});
|
||||
this.fx.invalidate(r.userId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import Decimal from 'decimal.js';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { transferInput, toBusinessDate } from './validation';
|
||||
import { businessTime } from './calculation';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
const visibility = { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) };
|
||||
const rows = await this.db.transfer.findMany({
|
||||
where: { userId: r.userId, source: visibility, target: visibility },
|
||||
include: { source: { select: { name: true } }, target: { select: { name: true } } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||
...v,
|
||||
date: businessTime(effectiveDate),
|
||||
}));
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body),
|
||||
when = toBusinessDate(v.date);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
if (v.requestId) {
|
||||
const existing = await tx.transfer.findFirst({
|
||||
where: { id: v.requestId, userId: r.userId },
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.sourceId !== v.sourceId ||
|
||||
existing.targetId !== v.targetId ||
|
||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||
+existing.effectiveDate !== +when ||
|
||||
existing.notes !== v.notes
|
||||
)
|
||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||
return { id: existing.id };
|
||||
}
|
||||
}
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
id: { in: [v.sourceId, v.targetId] },
|
||||
userId: r.userId,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||
},
|
||||
});
|
||||
if (accounts.length !== 2)
|
||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||
target = accounts.find((p) => p.id === v.targetId)!;
|
||||
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 debit = new Decimal(v.amount).plus(v.fee),
|
||||
before = new Decimal(source.revisions[0].amount.toString());
|
||||
if (before.lt(debit)) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(v.received);
|
||||
if (after.gte('10000000000000000'))
|
||||
throw new BadRequestException('到账后的金额超出支持范围');
|
||||
const outgoing = await tx.revision.create({
|
||||
data: {
|
||||
positionId: source.id,
|
||||
amount: before.minus(debit).toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_out',
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
data: {
|
||||
positionId: target.id,
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_in',
|
||||
},
|
||||
});
|
||||
const row = await tx.transfer.create({
|
||||
data: {
|
||||
id: v.requestId,
|
||||
userId: r.userId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
sourceRevisionId: outgoing.id,
|
||||
targetRevisionId: incoming.id,
|
||||
sourceCurrency: source.currency,
|
||||
targetCurrency: target.currency,
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,15 @@ export const revisionInput = z
|
||||
date: businessDate,
|
||||
notes,
|
||||
reason: z
|
||||
.enum(['initial', 'balance', 'valuation', 'repayment', 'correction'])
|
||||
.enum([
|
||||
'initial',
|
||||
'balance',
|
||||
'valuation',
|
||||
'repayment',
|
||||
'correction',
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
])
|
||||
.default('balance'),
|
||||
})
|
||||
.strict();
|
||||
@@ -108,3 +116,22 @@ export const credentials = z
|
||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history']);
|
||||
export const hiddenMenus = z
|
||||
.array(menuKey)
|
||||
.max(5)
|
||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||
export const transferInput = z
|
||||
.object({
|
||||
requestId: z.string().uuid().optional(),
|
||||
sourceId: z.string().uuid(),
|
||||
targetId: z.string().uuid(),
|
||||
amount: amount.refine((v) => /[1-9]/.test(v), '转出金额必须大于零'),
|
||||
received: amount.refine((v) => /[1-9]/.test(v), '到账金额必须大于零'),
|
||||
fee: amount.default('0'),
|
||||
date: businessDate,
|
||||
notes,
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账');
|
||||
+18
-6
@@ -16,6 +16,7 @@ const files = [
|
||||
'links.json',
|
||||
'rates.json',
|
||||
'icons.json',
|
||||
'transfers.json',
|
||||
] as const;
|
||||
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
|
||||
export function packBackup(b: Backup) {
|
||||
@@ -32,6 +33,7 @@ export function packBackup(b: Backup) {
|
||||
'links.json': b.links,
|
||||
'rates.json': b.rates,
|
||||
'icons.json': b.icons || [],
|
||||
'transfers.json': b.transfers || [],
|
||||
};
|
||||
const contents = Object.fromEntries(
|
||||
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
|
||||
@@ -39,7 +41,7 @@ export function packBackup(b: Backup) {
|
||||
contents['manifest.json'] = JSON.stringify(
|
||||
{
|
||||
format: 'worthpath',
|
||||
version: 4,
|
||||
version: 5,
|
||||
exportedAt: b.exportedAt,
|
||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||
},
|
||||
@@ -114,18 +116,22 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
const manifest = z
|
||||
.object({
|
||||
format: z.literal('worthpath'),
|
||||
version: z.union([z.literal(3), z.literal(4)]),
|
||||
version: z.union([z.literal(3), z.literal(4), z.literal(5)]),
|
||||
exportedAt: z.iso.datetime(),
|
||||
files: z
|
||||
.array(
|
||||
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
|
||||
)
|
||||
.min(files.length - 1)
|
||||
.min(files.length - 2)
|
||||
.max(files.length),
|
||||
})
|
||||
.strict()
|
||||
.parse(parse('manifest.json'));
|
||||
const expected = manifest.version === 3 ? files.filter((f) => f !== 'icons.json') : [...files];
|
||||
const expected = files.filter(
|
||||
(f) =>
|
||||
(manifest.version >= 4 || f !== 'icons.json') &&
|
||||
(manifest.version >= 5 || f !== 'transfers.json'),
|
||||
);
|
||||
if (
|
||||
contents.size !== expected.length + 1 ||
|
||||
new Set(manifest.files.map((f) => f.name)).size !== expected.length ||
|
||||
@@ -137,7 +143,12 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
.object({
|
||||
baseCurrency: z.string(),
|
||||
preferences: z
|
||||
.object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) })
|
||||
.object({
|
||||
showSidebar: z.boolean().optional(),
|
||||
hiddenMenus: z.array(z.string()).optional(),
|
||||
showNotes: z.boolean().optional(),
|
||||
idleMinutes: z.number().int().min(0).max(1440),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
@@ -169,7 +180,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })),
|
||||
links: parse('links.json'),
|
||||
rates: parse('rates.json'),
|
||||
...(manifest.version === 4 ? { icons: parse('icons.json') } : {}),
|
||||
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
|
||||
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
|
||||
};
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
|
||||
Reference in new issue
Block a user