perf: optimize portfolio queries and workflows

This commit is contained in:
陈煜 committed 2026-10-02 12:44:07 +08:00
1 parent da98d02661
commit a7e0a0fe58
45 files changed
+4446 -637

No files matched your search

+3 -2
View File
@@ -5,11 +5,12 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
"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: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:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
"dependencies": {
@@ -0,0 +1,6 @@
CREATE INDEX `Revision_positionId_effectiveDate_sequence_idx` ON `Revision` (`positionId`, `effectiveDate`, `sequence`);
CREATE INDEX `Revision_effectiveDate_sequence_idx` ON `Revision` (`effectiveDate`, `sequence`);
CREATE INDEX `Revision_positionId_reason_effectiveDate_sequence_idx` ON `Revision` (`positionId`, `reason`, `effectiveDate`, `sequence`);
DROP INDEX `Revision_positionId_effectiveDate_idx` ON `Revision`;
CREATE INDEX `Transfer_userId_effectiveDate_id_idx` ON `Transfer` (`userId`, `effectiveDate`, `id`);
DROP INDEX `Transfer_userId_effectiveDate_idx` ON `Transfer`;
@@ -0,0 +1,3 @@
-- Bounded per-position queries replace the global scan. EXPLAIN ANALYZE showed
-- this competing index could scan other accounts before finding a match.
DROP INDEX `Revision_effectiveDate_sequence_idx` ON `Revision`;
@@ -0,0 +1 @@
ALTER TABLE `Transfer` ADD COLUMN `operation` VARCHAR(16) NOT NULL DEFAULT 'transfer';
@@ -0,0 +1,97 @@
-- 仅增加中文注释;列定义来自现有 MySQL SHOW CREATE TABLE,保留类型、默认值、空值和自增属性。
ALTER TABLE `User`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录用户名,唯一',
MODIFY COLUMN `passwordHash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录密码的 bcrypt 哈希,不存储明文',
MODIFY COLUMN `baseCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '本位币代码',
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
MODIFY COLUMN `idleMinutes` int NOT NULL DEFAULT '30' COMMENT '无操作退出分钟数,0 表示关闭',
MODIFY COLUMN `hiddenMenus` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '隐藏菜单标识列表',
MODIFY COLUMN `showNotes` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否显示备注',
COMMENT = '用户及个人设置';
ALTER TABLE `Session`
MODIFY COLUMN `id` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '随机会话令牌的 SHA-256 摘要',
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
MODIFY COLUMN `expiresAt` datetime(3) NOT NULL COMMENT '会话过期时间,UTC',
MODIFY COLUMN `lastActivity` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '最后有效交互时间,UTC',
MODIFY COLUMN `revealUntil` datetime(3) DEFAULT NULL COMMENT '隐藏项目授权截止时间,UTC;空表示未解锁',
MODIFY COLUMN `backupDigest` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已下载备份内容的 SHA-256 摘要',
MODIFY COLUMN `backupExpiresAt` datetime(3) DEFAULT NULL COMMENT '备份清空确认凭据截止时间,UTC',
COMMENT = '登录会话与隐藏项目查看授权';
ALTER TABLE `Position`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
MODIFY COLUMN `kind` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目类型:account 账户、asset 独立资产、debt 债务',
MODIFY COLUMN `side` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '统计方向:asset 资产或借出应收、liability 负债或借入应付',
MODIFY COLUMN `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '显示名称',
MODIFY COLUMN `category` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目分类代码',
MODIFY COLUMN `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始币种代码',
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
MODIFY COLUMN `archived` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否归档;归档项目仍计入总额',
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
MODIFY COLUMN `importedFromId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备份导入来源项目标识,用于重复导入识别',
MODIFY COLUMN `hidden` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否隐藏;未解锁时不参与可见范围统计',
MODIFY COLUMN `iconId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联账户图标标识',
COMMENT = '持有项目:账户、独立资产和借入借出债务';
ALTER TABLE `Revision`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `positionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属持有项目标识',
MODIFY COLUMN `amount` decimal(24,8) NOT NULL COMMENT '变更后的原币绝对余额,不是收支增量,禁止对全部历史求和',
MODIFY COLUMN `effectiveDate` datetime(3) NOT NULL COMMENT '北京时间业务时间对应的 UTC,精确到分钟',
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
MODIFY COLUMN `reason` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更原因:初始、余额、估值、转账或借贷配对操作',
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
MODIFY COLUMN `sequence` int NOT NULL AUTO_INCREMENT COMMENT '数据库自增唯一顺序,同业务时间按此排序',
COMMENT = '余额历史:金额为变更后的绝对余额';
ALTER TABLE `PositionLink`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `sourceId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联来源项目标识,通常为债务',
MODIFY COLUMN `targetId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联目标项目标识,通常为账户或资产',
COMMENT = '项目关联关系,不参与金额求和';
ALTER TABLE `ExchangeRate`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
MODIFY COLUMN `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始币种代码',
MODIFY COLUMN `baseCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本位币代码',
MODIFY COLUMN `date` date NOT NULL COMMENT '汇率业务日期',
MODIFY COLUMN `rate` decimal(24,12) NOT NULL COMMENT '每单位原币对应的本位币金额',
MODIFY COLUMN `source` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率来源标识,保留已有历史来源',
COMMENT = '用户原币兑本位币的历史汇率';
ALTER TABLE `Icon`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `ownerId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传用户标识;空可表示系统图标或原用户已删除',
MODIFY COLUMN `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '显示名称',
MODIFY COLUMN `shared` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否公开共享给已登录用户',
MODIFY COLUMN `hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化图标内容的 SHA-256 摘要',
MODIFY COLUMN `data` mediumblob NOT NULL COMMENT '规范化静态 PNG 图片二进制',
MODIFY COLUMN `source` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选公开来源网址',
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
COMMENT = '可复用账户图标';
ALTER TABLE `Transfer`
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
MODIFY COLUMN `importedFromId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备份导入来源项目标识,用于重复导入识别',
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
MODIFY COLUMN `sourceId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源资产账户标识',
MODIFY COLUMN `targetId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标账户或债务标识',
MODIFY COLUMN `sourceRevisionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源账户配对余额历史标识',
MODIFY COLUMN `targetRevisionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标项目配对余额历史标识',
MODIFY COLUMN `sourceCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源账户币种',
MODIFY COLUMN `targetCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标项目币种',
MODIFY COLUMN `amount` decimal(24,8) NOT NULL COMMENT '本次来源账户本金金额,不是变更后余额',
MODIFY COLUMN `received` decimal(24,8) NOT NULL COMMENT '本次目标到账金额或债务本金金额',
MODIFY COLUMN `fee` decimal(24,8) NOT NULL COMMENT '来源币种手续费;负数表示优惠',
MODIFY COLUMN `effectiveDate` datetime(3) NOT NULL COMMENT '北京时间业务时间对应的 UTC,精确到分钟',
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
MODIFY COLUMN `operation` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'transfer' COMMENT '操作类型:transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款',
COMMENT = '资金往来及账户与债务的双边配对记账';
+85 -2
View File
@@ -5,15 +5,25 @@ datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
/// 用户及个人设置
model User {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 登录用户名,唯一
username String @unique @db.VarChar(64)
/// 登录密码的 bcrypt 哈希,不存储明文
passwordHash String @db.VarChar(255)
/// 本位币代码
baseCurrency String @default("CNY") @db.Char(3)
/// 隐藏菜单标识列表
hiddenMenus String @default("") @db.VarChar(128)
/// 是否显示备注
showNotes Boolean @default(true)
/// 无操作退出分钟数,0 表示关闭
idleMinutes Int @default(30)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
/// 记录更新时间,UTC
updatedAt DateTime @updatedAt
positions Position[]
rates ExchangeRate[]
@@ -21,32 +31,55 @@ model User {
icons Icon[]
transfers Transfer[]
}
/// 登录会话与隐藏项目查看授权
model Session {
/// 随机会话令牌的 SHA-256 摘要
id String @id @db.Char(64)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 会话过期时间,UTC
expiresAt DateTime
/// 最后有效交互时间,UTC
lastActivity DateTime @default(now())
/// 隐藏项目授权截止时间,UTC;空表示未解锁
revealUntil DateTime?
/// 已下载备份内容的 SHA-256 摘要
backupDigest String? @db.Char(64)
/// 备份清空确认凭据截止时间,UTC
backupExpiresAt DateTime?
@@index([userId])
}
/// 持有项目:账户、独立资产和借入借出债务
model Position {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 备份导入来源项目标识,用于重复导入识别
importedFromId String? @db.Char(36)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 项目类型:account 账户、asset 独立资产、debt 债务
kind String @db.VarChar(16)
/// 统计方向:asset 资产或借出应收、liability 负债或借入应付
side String @db.VarChar(16)
/// 显示名称
name String @db.VarChar(100)
/// 项目分类代码
category String @db.VarChar(40)
/// 原始币种代码
currency String @db.Char(3)
/// 用户备注,原文保留
notes String @db.Text
/// 是否归档;归档项目仍计入总额
archived Boolean @default(false)
/// 是否隐藏;未解锁时不参与可见范围统计
hidden Boolean @default(false)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
/// 记录更新时间,UTC
updatedAt DateTime @updatedAt
/// 关联账户图标标识
iconId String? @db.Char(36)
icon Icon? @relation(fields:[iconId],references:[id],onDelete:SetNull)
revisions Revision[]
@@ -57,73 +90,123 @@ model Position {
@@index([userId,kind])
@@unique([userId,importedFromId])
}
/// 余额历史:金额为变更后的绝对余额
model Revision {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 数据库自增唯一顺序,同业务时间按此排序
sequence Int @unique @default(autoincrement())
/// 所属持有项目标识
positionId String @db.Char(36)
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
/// 变更后的原币绝对余额,不是收支增量,禁止对全部历史求和
amount Decimal @db.Decimal(24,8)
/// 北京时间业务时间对应的 UTC,精确到分钟
effectiveDate DateTime @db.DateTime(3)
/// 用户备注,原文保留
notes String @db.Text
/// 变更原因:初始、余额、估值、转账或借贷配对操作
reason String @db.VarChar(20)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
/// 记录更新时间,UTC
updatedAt DateTime @updatedAt
@@index([positionId,effectiveDate])
@@index([positionId,effectiveDate,sequence])
@@index([positionId,reason,effectiveDate,sequence])
}
/// 项目关联关系,不参与金额求和
model PositionLink {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 关联来源项目标识,通常为债务
sourceId String @db.Char(36)
/// 关联目标项目标识,通常为账户或资产
targetId String @db.Char(36)
source Position @relation("Source",fields:[sourceId],references:[id],onDelete:Cascade)
target Position @relation("Target",fields:[targetId],references:[id],onDelete:Cascade)
@@unique([sourceId,targetId])
}
/// 用户原币兑本位币的历史汇率
model ExchangeRate {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 原始币种代码
currency String @db.Char(3)
/// 本位币代码
baseCurrency String @db.Char(3)
/// 汇率业务日期
date DateTime @db.Date
/// 每单位原币对应的本位币金额
rate Decimal @db.Decimal(24,12)
/// 汇率来源标识,保留已有历史来源
source String @db.VarChar(30)
@@unique([userId,currency,baseCurrency,date])
}
/// 可复用账户图标
model Icon {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 上传用户标识;空可表示系统图标或原用户已删除
ownerId String? @db.Char(36)
owner User? @relation(fields:[ownerId],references:[id],onDelete:SetNull)
/// 显示名称
name String @db.VarChar(100)
/// 是否公开共享给已登录用户
shared Boolean @default(false)
/// 规范化图标内容的 SHA-256 摘要
hash String @db.Char(64)
/// 规范化静态 PNG 图片二进制
data Bytes @db.MediumBlob
/// 可选公开来源网址
source String? @db.VarChar(500)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
positions Position[]
@@unique([ownerId,hash,shared])
@@index([shared,name])
}
/// 资金往来及账户与债务的双边配对记账
model Transfer {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 备份导入来源项目标识,用于重复导入识别
importedFromId String? @db.Char(36)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 来源资产账户标识
sourceId String @db.Char(36)
/// 目标账户或债务标识
targetId String @db.Char(36)
source Position @relation("TransferSource",fields:[sourceId],references:[id],onDelete:Cascade)
target Position @relation("TransferTarget",fields:[targetId],references:[id],onDelete:Cascade)
/// 来源账户配对余额历史标识
sourceRevisionId String @unique @db.Char(36)
/// 目标项目配对余额历史标识
targetRevisionId String @unique @db.Char(36)
/// 来源账户币种
sourceCurrency String @db.Char(3)
/// 目标项目币种
targetCurrency String @db.Char(3)
/// 本次来源账户本金金额,不是变更后余额
amount Decimal @db.Decimal(24,8)
/// 本次目标到账金额或债务本金金额
received Decimal @db.Decimal(24,8)
/// 来源币种手续费;负数表示优惠
fee Decimal @db.Decimal(24,8)
/// 北京时间业务时间对应的 UTC,精确到分钟
effectiveDate DateTime @db.DateTime(3)
/// 用户备注,原文保留
notes String @db.Text
/// 操作类型:transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款
operation String @default("transfer") @db.VarChar(16)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
@@unique([userId,importedFromId])
@@index([userId,effectiveDate])
@@index([userId,effectiveDate,id])
}
+253
View File
@@ -0,0 +1,253 @@
import 'dotenv/config';
import { PrismaClient, Prisma } from '@prisma/client';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { history, overview, totals, trend } from '../src/calculation';
import { currentPositions, historyPage, trendData, currentRates } from '../src/queries';
import { today, toBusinessDate } from '../src/validation';
import { writeFileSync } from 'node:fs';
// Dedicated random users only; no existing user's rows are read or changed.
const phase = process.argv[2] || 'after';
const db = new PrismaClient({
transactionOptions: { timeout: 120000, maxWait: 10000 },
log: [{ emit: 'event', level: 'query' }],
});
let events: { query: string; params: string; duration: number }[] = [];
db.$on('query', (e) => events.push(e));
const results: unknown[] = [];
async function measure(name: string, work: () => Promise<unknown>) {
console.log('Measuring ' + name);
const times: number[] = [];
let bytes = 0,
queries = 0;
for (let i = 0; i < 3; i++) {
events = [];
const start = performance.now();
const value = await work();
times.push(performance.now() - start);
bytes = Buffer.byteLength(JSON.stringify(value));
queries = events.length;
}
return { name, medianMs: Number(times.sort((a, b) => a - b)[1].toFixed(2)), bytes, queries };
}
async function plan(event: (typeof events)[number]) {
const params = JSON.parse(event.params).map((p: unknown) =>
typeof p === 'string' && /^\d{4}-\d\d-\d\d[ T]\d\d:/.test(p) ? new Date(p) : p,
);
try {
return await db.$queryRawUnsafe('EXPLAIN ANALYZE ' + event.query, ...params);
} catch {
return await db.$queryRawUnsafe('EXPLAIN ' + event.query, ...params);
}
}
async function run() {
for (const [accounts, count] of [
[1, 10000],
[1, 100000],
[10, 10000],
]) {
const user = await db.user.create({
data: { username: 'wp_perf_' + randomUUID(), passwordHash: 'not-a-login-hash' },
});
try {
const ids: string[] = [];
const end = today(),
start = new Date(+new Date(end) - 89 * 86400000).toISOString().slice(0, 10);
for (let a = 0; a < accounts; a++) {
const p = await db.position.create({
data: {
userId: user.id,
kind: 'account',
side: 'asset',
name: 'benchmark',
category: 'bank',
currency: 'CNY',
notes: '',
},
});
ids.push(p.id);
for (let offset = 0; offset < count; offset += 1000) {
await db.revision.createMany({
data: Array.from({ length: Math.min(1000, count - offset) }, (_, n) => ({
positionId: p.id,
amount: String(offset + n + 1),
notes: '',
reason: 'balance',
effectiveDate: new Date(
+toBusinessDate(start) + Math.floor(((offset + n) * 89) / (count - 1)) * 86400000,
),
})),
});
}
}
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: user.id,
expiresAt: new Date(Date.now() + 3600000),
},
});
const httpBase = process.env.TEST_API_URL || 'http://127.0.0.1:33101/api';
const http = async (path: string, base = httpBase) => {
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
if (!response.ok) throw Error('HTTP benchmark status ' + response.status);
return response.json();
};
const oldList = () =>
db.$transaction(async (tx) => {
const rows = await tx.position.findMany({
where: { userId: user.id },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
});
return rows.map((p) => ({
...p,
userId: undefined,
amount: p.revisions.at(-1)?.amount.toString() || '0',
history: history(p),
}));
});
const oldOverview = () =>
db.$transaction(
async (tx) => {
const rows = await tx.position.findMany({
where: { userId: user.id },
include: { revisions: true },
});
return overview(rows, [], 'CNY', end);
},
{ timeout: 120000 },
);
const baseline = [
await measure('old positions', oldList),
await measure('old overview', oldOverview),
];
const optimized = [
await measure('new positions', () =>
db.$transaction(async (tx) => {
const rows = await currentPositions(tx, user.id, false);
const rates = await currentRates(tx, user.id, ['CNY'], 'CNY', end);
const amounts = new Map(totals(rows, rates, 'CNY', end).items.map((p) => [p.id, p]));
return rows.map(({ revisions, userId, ...p }) => ({
...p,
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
rateDate: amounts.get(p.id)!.rateDate,
}));
}),
),
await measure('new current overview', () =>
db.$transaction(async (tx) =>
totals(await currentPositions(tx, user.id, false), [], 'CNY', end),
),
),
await measure('new history page', () =>
db.$transaction((tx) => historyPage(tx, user.id, false, { limit: 50 })),
),
await measure('new trend', () =>
db.$transaction(
async (tx) => {
const data = await trendData(tx, user.id, false, start, end);
return trend(data.positions, data.rates, 'CNY', start, end);
},
{ timeout: 30000 },
),
),
];
events = [];
await db.position.findMany({
where: { userId: user.id },
include: {
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
},
});
const nestedSql = events.filter((e) => e.query.startsWith('SELECT')).map((e) => e.query);
events = [];
await currentPositions(db, user.id, false);
const latestEvent = events.find((e) => e.query.includes('SELECT r.*'))!;
const latestPlan = await plan(latestEvent);
events = [];
await historyPage(db, user.id, false, { limit: 50 });
const pageEvent = events.find((e) => e.query.includes('AS beforeAmount'))!;
const candidateEvents = events.filter(
(e) =>
e.query.includes('Revision') &&
!e.query.includes('beforeAmount') &&
e.query.startsWith('SELECT'),
);
const pagePlan = await plan(pageEvent);
const candidatePlans = await Promise.all(candidateEvents.map(plan));
const first = await historyPage(db, user.id, false, { limit: 50 });
const second = await historyPage(db, user.id, false, { limit: 50, cursor: first.nextCursor });
const deep = await historyPage(db, user.id, false, { limit: 50, to: start });
if (
first.items.some((a) => second.items.some((b) => a.id === b.id)) ||
deep.items.some((r) => r.delta !== '1')
)
throw Error('Pagination boundary mismatch');
const httpResults = [
await measure('HTTP positions', () => http('/positions?kind=account')),
await measure('HTTP overview', () => http('/overview')),
await measure('HTTP history', () => http('/history?limit=50')),
await measure('HTTP trend', () => http('/trend?from=' + start + '&to=' + end)),
];
const oldHttpBase = process.env.BASELINE_API_URL;
const baselineHttp = oldHttpBase
? [
await measure('old HTTP positions', () => http('/positions', oldHttpBase)),
await measure('old HTTP overview', () => http('/overview', oldHttpBase)),
]
: [];
results.push({
accounts,
revisionsPerAccount: count,
baseline,
optimized,
httpResults,
baselineHttp,
nestedSql,
latestPlan,
pagePlan,
candidatePlans,
});
writeFileSync(
`../../docs/performance-${phase}.json`,
JSON.stringify(
{ phase, node: process.version, results },
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
2,
),
);
console.log(
JSON.stringify({ phase, accounts, count, baseline, optimized, httpResults, baselineHttp }),
);
} finally {
await db.user.delete({ where: { id: user.id } });
}
}
writeFileSync(
`../../docs/performance-${phase}.json`,
JSON.stringify(
{ phase, node: process.version, results },
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
2,
),
);
}
run()
.catch((e) => {
console.error(
'Benchmark failed: ' +
(e.code || e.constructor.name) +
' ' +
String(e.message)
.replace(/mysql:\/\/[^\s]+/g, '[redacted]')
.slice(-600),
);
process.exitCode = 1;
})
.finally(() => db.$disconnect());
+11 -5
View File
@@ -1,3 +1,5 @@
import { movementDeltas } from './movement';
import { pairedReasons } from './validation';
import {
Controller,
Get,
@@ -159,9 +161,11 @@ export function validateBackup(raw: unknown) {
!source ||
!target ||
source.kind !== 'account' ||
target.kind !== 'account' ||
source.side !== 'asset' ||
target.side !== 'asset' ||
(t.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
source.currency !== t.sourceCurrency ||
target.currency !== t.targetCurrency
)
@@ -169,9 +173,10 @@ export function validateBackup(raw: unknown) {
transferIds.add(origin);
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
throw new BadRequestException('同币种转账金额不一致');
const deltas = movementDeltas(t.operation, t.amount, t.received, t.fee);
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)],
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
] as const) {
const ordered = [...p.revisions].sort(
(a, b) =>
@@ -195,7 +200,7 @@ export function validateBackup(raw: unknown) {
}
if (
b.positions.some((p) =>
p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)),
p.revisions.some((r) => pairedReasons.includes(r.reason) && !usedRevisions.has(r.id)),
)
)
throw new BadRequestException('缺少配对转账记录');
@@ -614,6 +619,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
amount: t.amount,
received: t.received,
fee: t.fee,
operation: t.operation,
effectiveDate: toBusinessDate(t.date),
notes: t.notes,
createdAt: new Date(t.createdAt),
+76
View File
@@ -142,3 +142,79 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
.slice(0, 20),
};
}
// Sort once, advance each revision/rate once, and retain only the running balances.
// Sampling uses end-of-period balances; attribution sums daily changes in the bucket.
export function trend(
positions: Holding[],
rates: Rate[],
base: string,
from: string,
to: string,
grain: 'day' | 'week' | 'month' = 'day',
) {
const streams = positions.map((p) => ({
p,
rows: [...p.revisions].sort(compareRevisions),
index: 0,
current: [] as Holding['revisions'],
}));
const fxRows = rates.filter((r) => r.baseCurrency === base).sort((a, b) => +a.date - +b.date);
let fxIndex = 0;
const fx = new Map<string, Rate>();
function advance(d: string) {
for (const s of streams)
while (s.index < s.rows.length && businessDay(s.rows[s.index].effectiveDate) <= d)
s.current = [s.rows[s.index++]];
while (fxIndex < fxRows.length && day(fxRows[fxIndex].date) <= d) {
const r = fxRows[fxIndex++];
fx.set(r.currency, r);
}
return totals(
streams.map((s) => ({ ...s.p, revisions: s.current })),
[...fx.values()],
base,
d,
);
}
let previous = advance(new Date(+new Date(from) - 86400000).toISOString().slice(0, 10));
const output: (Omit<ReturnType<typeof totals>, 'items'> & {
balanceChange: string | null;
fxChange: string | null;
})[] = [];
let balance = new Decimal(0),
exchange = new Decimal(0),
complete = true;
for (let time = +new Date(from); time <= +new Date(to); time += 86400000) {
const d = new Date(time).toISOString().slice(0, 10),
value = advance(d);
if (previous.complete && value.complete) {
let revalued = new Decimal(0);
for (const item of previous.items) {
const rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate;
const v = new Decimal(item.amount).mul(rate?.toString() || '0');
revalued = revalued.plus(item.side === 'asset' ? v : v.neg());
}
exchange = exchange.plus(revalued.minus(previous.net));
balance = balance.plus(new Decimal(value.net).minus(revalued));
} else complete = false;
previous = value;
const next = new Date(time + 86400000);
if (
grain === 'day' ||
d === to ||
(grain === 'week' ? next.getUTCDay() === 1 : next.getUTCDate() === 1)
) {
const { items, ...total } = value;
output.push({
...total,
balanceChange: complete ? balance.toFixed(2) : null,
fxChange: complete ? exchange.toFixed(2) : null,
});
balance = new Decimal(0);
exchange = new Decimal(0);
complete = true;
}
}
return output;
}
+18 -1
View File
@@ -1,7 +1,24 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
@Injectable()
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.$transaction(work, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
// Serializable deadlock/write conflict; retry the entire atomic operation.
const retryable =
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2034' ||
(error.code === 'P2010' && ['1213', '1205'].includes(String(error.meta?.code))));
if (!retryable || attempt >= 3) throw error;
await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt + Math.random() * 20));
}
}
}
async onModuleInit() {
await this.$connect();
}
+23
View File
@@ -0,0 +1,23 @@
import Decimal from 'decimal.js';
import { BadRequestException } from '@nestjs/common';
export type Movement = 'transfer' | 'borrow' | 'lend' | 'collect' | 'repay';
export function movementDeltas(operation: Movement, amount: string, received: string, fee: string) {
const incoming = operation === 'borrow' || operation === 'collect';
const principal = new Decimal(amount),
charge = new Decimal(fee),
debt = new Decimal(received);
if (operation === 'transfer')
return {
source: principal.plus(charge).neg(),
target: debt,
sourceReason: 'transfer_out',
targetReason: 'transfer_in',
};
if (incoming && charge.gt(principal)) throw new BadRequestException('收款手续费不能超过收款金额');
return {
source: incoming ? principal.minus(charge) : principal.plus(charge).neg(),
target: operation === 'borrow' || operation === 'lend' ? debt : debt.neg(),
sourceReason: 'loan_' + operation,
targetReason: 'loan_' + operation,
};
}
+43
View File
@@ -54,6 +54,49 @@ export function setupOpenApi(app: INestApplication) {
const operation = entry[method];
if (!operation) continue;
operation.summary = `${method.toUpperCase()} ${path}`;
if (
method === 'get' &&
['/api/history', '/api/positions/{id}/history', '/api/transfers'].includes(path)
) {
operation.parameters = [
...(operation.parameters || []),
{
name: 'limit',
in: 'query',
schema: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
},
{
name: 'cursor',
in: 'query',
schema: { type: 'string' },
description: '上页 nextCursor;业务时间及唯一顺序键降序,写入或更正后重新读取第一页',
},
...['from', 'to'].map((name) => ({
name,
in: 'query' as const,
schema: { type: 'string', format: 'date' },
})),
];
operation.responses['200'] = {
description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额',
};
}
if (method === 'get' && path === '/api/trend') {
operation.parameters = [
...['from', 'to'].map((name) => ({
name,
in: 'query' as const,
schema: { type: 'string', format: 'date' },
})),
{
name: 'grain',
in: 'query',
schema: { type: 'string', enum: ['day', 'week', 'month'], default: 'day' },
},
];
operation.description =
'默认90天;日最多366天,周最多1096天,月最多3653天。取北京时间期末余额,归因累计每日变化;不返回每天项目明细。';
}
if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path))
operation.security = [];
const schema = bodies[method.toUpperCase() + ' ' + path];
+173 -120
View File
@@ -7,14 +7,23 @@ import {
Body,
Req,
Param,
Query,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { Database } from './database';
import { UserRequest } from './auth';
import { positionInput, positionMeta, revisionInput, today, toBusinessDate } from './validation';
import { history, overview } from './calculation';
import {
positionInput,
positionMeta,
revisionInput,
today,
toBusinessDate,
pairedReasons,
} from './validation';
import { totals, trend } from './calculation';
import { currentPositions, currentRates, historyPage, trendData, trendInput } from './queries';
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
@@ -27,35 +36,70 @@ export class PortfolioController {
private icons: IconsService,
) {}
private async own(userId: string, id: string, revealed = false) {
const p = await this.db.position.findFirst({
where: { id, userId, ...(revealed ? {} : { hidden: false }) },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
});
const [p] = await this.db.$transaction((tx) => currentPositions(tx, userId, revealed, id));
if (!p) throw new NotFoundException('项目不存在');
return p;
}
@Get('positions') async list(@Req() r: UserRequest) {
const rows = await this.db.position.findMany({
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
include: {
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
outgoing: true,
},
orderBy: { createdAt: 'desc' },
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
return this.db.$transaction(async (tx) => {
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true },
});
const rates = await currentRates(
tx,
r.userId,
rows.map((p) => p.currency),
user.baseCurrency,
today(),
);
const amounts = new Map(
totals(rows, rates, user.baseCurrency, today()).items.map((p) => [p.id, p]),
);
return rows.map(({ revisions, userId, ...p }) => ({
...p,
amount: revisions[0]?.amount.toString() || '0',
converted: amounts.get(p.id)!.converted,
rateDate: amounts.get(p.id)!.rateDate,
}));
});
return rows.map((p) => ({
...p,
userId: undefined,
amount: p.revisions.at(-1)?.amount.toString() || '0',
history: history(p),
}));
}
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
const p = await this.own(r.userId, id, r.revealed);
return { ...p, userId: undefined, history: history(p) };
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
}
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
}
@Get('positions/:id/history') async positionHistory(
@Req() r: UserRequest,
@Param('id') id: string,
@Query() query: Record<string, string>,
) {
return this.db.$transaction(async (tx) => {
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
});
}
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
const q = trendInput(query);
return this.db.$transaction(
async (tx) => {
const data = await trendData(tx, r.userId, r.revealed, q.from, q.to);
return {
...q,
baseCurrency: data.base,
revealed: r.revealed,
items: trend(data.positions, data.rates, data.base, q.from, q.to, q.grain),
};
},
{ timeout: 30000 },
);
}
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
const v = positionInput.parse(b),
@@ -102,41 +146,41 @@ 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({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
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'] } },
if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口');
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
const lastTransfer = await tx.revision.findFirst({
where: { positionId: p.id, reason: { in: pairedReasons } },
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({
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
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({
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
});
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
throw new BadRequestException('还款后的欠款不能高于该业务日期的原欠款');
}
return tx.revision.create({
data: {
positionId: p.id,
amount: v.amount,
effectiveDate: toBusinessDate(v.date),
notes: v.notes,
reason: v.reason,
},
});
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
throw new BadRequestException('还款后的欠款不能高于该业务日期的原欠款');
}
return tx.revision.create({
data: {
positionId: p.id,
amount: v.amount,
effectiveDate: toBusinessDate(v.date),
notes: v.notes,
reason: v.reason,
},
});
});
}
@Put('positions/:id/revisions/:revisionId') async correct(
@Req() r: UserRequest,
@@ -145,40 +189,45 @@ export class PortfolioController {
@Body() b: unknown,
) {
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 };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
return this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`,
);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
if (!original) throw new NotFoundException('历史记录不存在');
const transfer = await tx.revision.findFirst({
where: {
positionId: p.id,
reason: { in: pairedReasons },
OR: [
{ effectiveDate: { gt: original.effectiveDate } },
{ effectiveDate: original.effectiveDate, sequence: { gte: original.sequence } },
{ effectiveDate: { gt: toBusinessDate(v.date) } },
{ effectiveDate: toBusinessDate(v.date), sequence: { gte: original.sequence } },
],
},
select: { id: true },
});
if (transfer)
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 };
});
}
@Put('positions/:id/links') async link(
@@ -192,38 +241,42 @@ export class PortfolioController {
.parse(b);
if (new Set(targetIds).size !== targetIds.length || targetIds.includes(id))
throw new BadRequestException('关联不能重复或指向自身');
return this.db.$transaction(
async (tx) => {
const source = await tx.position.findFirst({
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
});
if (!source) throw new NotFoundException('债务不存在');
const count = await tx.position.count({
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
});
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
await tx.positionLink.deleteMany({ where: { sourceId: id } });
await tx.positionLink.createMany({
data: targetIds.map((targetId) => ({ sourceId: id, targetId })),
});
return { ok: true };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
);
return this.db.serial(async (tx) => {
const source = await tx.position.findFirst({
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
});
if (!source) throw new NotFoundException('债务不存在');
const count = await tx.position.count({
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
});
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
await tx.positionLink.deleteMany({ where: { sourceId: id } });
await tx.positionLink.createMany({
data: targetIds.map((targetId) => ({ sourceId: id, targetId })),
});
return { ok: true };
});
}
@Get('overview') async overview(@Req() r: UserRequest) {
void this.fx.daily(r.userId);
const [user, positions, rates] = await this.db.$transaction([
this.db.user.findUniqueOrThrow({
return this.db.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true },
}),
this.db.position.findMany({
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
include: { revisions: true },
}),
this.db.exchangeRate.findMany({ where: { userId: r.userId } }),
]);
return { ...overview(positions, rates, user.baseCurrency, today()), revealed: r.revealed };
});
const positions = await currentPositions(tx, r.userId, r.revealed);
const rates = await currentRates(
tx,
r.userId,
positions.map((p) => p.currency),
user.baseCurrency,
today(),
);
return {
baseCurrency: user.baseCurrency,
...totals(positions, rates, user.baseCurrency, today()),
revealed: r.revealed,
};
});
}
}
+288
View File
@@ -0,0 +1,288 @@
import { Prisma } from '@prisma/client';
import { z } from 'zod';
import { date, today, toBusinessDate } from './validation';
import { Holding, Rate, businessDay, businessTime, history } from './calculation';
import Decimal from 'decimal.js';
import { BadRequestException } from '@nestjs/common';
type Reader = Prisma.TransactionClient;
export const revisionOrder = [{ effectiveDate: 'desc' }, { sequence: 'desc' }] as const;
const cursorSchema = z
.object({ time: z.string().datetime(), key: z.string().min(1).max(64) })
.strict();
export function pageInput(query: unknown) {
const q = z
.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().max(512).optional(),
positionId: z.string().uuid().optional(),
from: date.optional(),
to: date.optional(),
})
.strict()
.parse(query);
const cursor = q.cursor
? cursorSchema.parse(JSON.parse(Buffer.from(q.cursor, 'base64url').toString()))
: null;
if (q.from && q.to && q.from > q.to) throw new BadRequestException('开始日期不能晚于结束日期');
return { ...q, cursor };
}
export const encodeCursor = (time: Date, key: string | number) =>
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
export async function transferPageIds(
db: Reader,
userId: string,
revealed: boolean,
q: ReturnType<typeof pageInput>,
) {
// Keep the ordered transfer index as the driving table. A visibility JOIN can
// otherwise make MySQL scan source accounts first and sort all their transfers.
return db.$queryRaw<{ id: string }[]>(Prisma.sql`
SELECT t.id FROM Transfer t FORCE INDEX (Transfer_userId_effectiveDate_id_idx)
STRAIGHT_JOIN Position s ON s.id = t.sourceId
STRAIGHT_JOIN Position d ON d.id = t.targetId
WHERE t.userId = ${userId} AND s.userId = ${userId} AND d.userId = ${userId}
${revealed ? Prisma.empty : Prisma.sql`AND s.hidden = false AND d.hidden = false`}
${q.positionId ? Prisma.sql`AND (t.sourceId = ${q.positionId} OR t.targetId = ${q.positionId})` : Prisma.empty}
${q.from ? Prisma.sql`AND t.effectiveDate >= ${toBusinessDate(q.from)}` : Prisma.empty}
${q.to ? Prisma.sql`AND t.effectiveDate < ${endOfDay(q.to)}` : Prisma.empty}
${
q.cursor
? Prisma.sql`AND (t.effectiveDate < ${new Date(q.cursor.time)} OR
(t.effectiveDate = ${new Date(q.cursor.time)} AND t.id < ${q.cursor.key}))`
: Prisma.empty
}
ORDER BY t.effectiveDate DESC, t.id DESC LIMIT ${q.limit + 1}`);
}
// Each branch has constant bounds so MySQL can use an index range even for a
// historical seed. Prisma 6's nested take can fetch unbounded rows for multiple parents.
export async function latestRevisions(db: Reader, ids: string[], before: Date) {
if (!ids.length) return [];
const result: (Holding['revisions'][number] & { positionId: string })[] = [];
// Bound UNION size for users with many positions.
for (let offset = 0; offset < ids.length; offset += 100) {
const branches = ids.slice(offset, offset + 100).map(
(id) => Prisma.sql`
(SELECT r.* FROM Revision r FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
WHERE r.positionId = ${id} AND r.effectiveDate < ${before}
ORDER BY r.effectiveDate DESC, r.sequence DESC LIMIT 1)`,
);
result.push(...(await db.$queryRaw<typeof result>(Prisma.join(branches, ' UNION ALL '))));
}
return result;
}
export async function currentPositions(
db: Reader,
userId: string,
revealed: boolean,
id?: string,
kind?: string,
) {
const positions = await db.position.findMany({
where: {
userId,
...(revealed ? {} : { hidden: false }),
...(id ? { id } : {}),
...(kind ? { kind } : {}),
},
include: {
outgoing: {
where: { target: { userId, ...(revealed ? {} : { hidden: false }) } },
include: { target: { select: { name: true, kind: true } } },
},
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const latest = await latestRevisions(
db,
positions.map((p) => p.id),
endOfDay(today()),
);
const byId = new Map(latest.map((r) => [r.positionId, r]));
return positions.map((p) => ({ ...p, revisions: byId.has(p.id) ? [byId.get(p.id)!] : [] }));
}
export async function currentRates(
db: Reader,
userId: string,
currencies: string[],
base: string,
before: string,
) {
const rows = await Promise.all(
[...new Set(currencies)]
.filter((c) => c !== base)
.map((currency) =>
db.exchangeRate.findFirst({
where: { userId, currency, baseCurrency: base, date: { lte: new Date(before) } },
orderBy: { date: 'desc' },
}),
),
);
return rows.filter((r): r is NonNullable<typeof r> => r !== null);
}
export async function historyPage(db: Reader, userId: string, revealed: boolean, query: unknown) {
const q = pageInput(query);
const positions = await db.position.findMany({
where: {
userId,
...(revealed ? {} : { hidden: false }),
...(q.positionId ? { id: q.positionId } : {}),
},
select: { id: true, name: true, kind: true, currency: true },
});
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
// sort an entire user's history (and evaluate the predecessor for every row).
const candidates = (
await Promise.all(
positions.map((p) =>
db.revision
.findMany({
where: {
positionId: p.id,
...(q.from || q.to
? {
effectiveDate: {
...(q.from ? { gte: toBusinessDate(q.from) } : {}),
...(q.to ? { lt: endOfDay(q.to) } : {}),
},
}
: {}),
...(q.cursor
? {
AND: [
{
OR: [
{ effectiveDate: { lt: new Date(q.cursor.time) } },
{
effectiveDate: new Date(q.cursor.time),
sequence: {
lt: z.coerce.number().int().positive().parse(q.cursor.key),
},
},
],
},
],
}
: {}),
},
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
take: q.limit + 1,
})
.then((rows) =>
rows.map((r) => ({ ...r, name: p.name, kind: p.kind, currency: p.currency })),
),
),
)
)
.flat()
.sort((a, b) => +b.effectiveDate - +a.effectiveDate || b.sequence - a.sequence)
.slice(0, q.limit + 1);
const predecessors = candidates.length
? await db.$queryRaw<{ id: string; beforeAmount: Prisma.Decimal | null }[]>(
Prisma.join(
candidates.map(
(r) => Prisma.sql`SELECT ${r.id} AS id, (
SELECT x.amount FROM Revision x FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
WHERE x.positionId = ${r.positionId} AND
(x.effectiveDate < ${r.effectiveDate} OR (x.effectiveDate = ${r.effectiveDate} AND x.sequence < ${r.sequence}))
ORDER BY x.effectiveDate DESC, x.sequence DESC LIMIT 1
) AS beforeAmount`,
),
' UNION ALL ',
),
)
: [];
const prior = new Map(predecessors.map((r) => [r.id, r.beforeAmount]));
const rows = candidates.map((r) => ({ ...r, beforeAmount: prior.get(r.id) || null }));
const more = rows.length > q.limit;
const items = rows.slice(0, q.limit).map((r) => ({
...history({
id: r.positionId,
name: r.name,
kind: r.kind,
side: '',
currency: r.currency,
revisions: [r],
})[0],
before: r.beforeAmount?.toString() || '0',
delta: new Decimal(r.amount.toString()).minus(r.beforeAmount?.toString() || '0').toFixed(),
}));
const last = rows[q.limit - 1];
return {
items,
nextCursor: more ? encodeCursor(last.effectiveDate, last.sequence) : null,
revealed,
};
}
export function trendInput(query: unknown) {
const to = today();
const from = new Date(+new Date(to) - 89 * 86400000).toISOString().slice(0, 10);
const q = z
.object({
from: date.default(from),
to: date.default(to),
grain: z.enum(['day', 'week', 'month']).default('day'),
})
.strict()
.parse(query);
const span = (+new Date(q.to) - +new Date(q.from)) / 86400000;
const max = q.grain === 'day' ? 365 : q.grain === 'week' ? 1095 : 3652;
if (span < 0 || span > max) throw new BadRequestException(`日期范围超出限制(${max + 1} 天)`);
return q;
}
export async function trendData(
db: Reader,
userId: string,
revealed: boolean,
from: string,
to: string,
) {
const positions = await db.position.findMany({
where: { userId, ...(revealed ? {} : { hidden: false }) },
});
const ids = positions.map((p) => p.id);
const seeds = await latestRevisions(db, ids, toBusinessDate(from));
// Only the final balance of each Beijing business day is needed. Rank in MySQL,
// rather than shipping every intraday revision to Node or the browser.
const daily = ids.length
? await db.$queryRaw<(Holding['revisions'][number] & { positionId: string })[]>(Prisma.sql`
SELECT id, sequence, positionId, amount, effectiveDate, '' AS notes, 'balance' AS reason FROM (
SELECT r.id, r.sequence, r.positionId, r.amount, r.effectiveDate,
ROW_NUMBER() OVER (PARTITION BY r.positionId, DATE(DATE_ADD(r.effectiveDate, INTERVAL 8 HOUR))
ORDER BY r.effectiveDate DESC, r.sequence DESC) AS rankInDay
FROM Revision r JOIN Position p ON p.id = r.positionId
WHERE p.userId = ${userId} ${revealed ? Prisma.empty : Prisma.sql`AND p.hidden = false`}
AND r.effectiveDate >= ${toBusinessDate(from)} AND r.effectiveDate < ${endOfDay(to)}
) ranked WHERE rankInDay = 1 ORDER BY effectiveDate, sequence`)
: [];
const byId = new Map<string, Holding['revisions']>();
for (const r of [...seeds, ...daily]) {
const list = byId.get(r.positionId) || [];
list.push(r);
byId.set(r.positionId, list);
}
const currencies = [...new Set(positions.map((p) => p.currency))];
const user = await db.user.findUniqueOrThrow({
where: { id: userId },
select: { baseCurrency: true },
});
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
const rates = await db.exchangeRate.findMany({
where: {
userId,
currency: { in: currencies },
baseCurrency: user.baseCurrency,
date: { gte: new Date(from), lte: new Date(to) },
},
});
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
return {
positions: positions.map((p) => ({ ...p, revisions: byId.get(p.id) || [] })),
rates: [...priorRates, ...rates],
base: user.baseCurrency,
};
}
+11 -6
View File
@@ -6,6 +6,7 @@ import {
Post,
Req,
Body,
Query,
OnModuleInit,
OnModuleDestroy,
BadGatewayException,
@@ -165,7 +166,8 @@ export class SettingsController {
private db: Database,
private fx: RatesService,
) {}
@Get('settings') async settings(@Req() r: UserRequest) {
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
const u = await this.db.user.findUniqueOrThrow({
where: { id: r.userId },
select: {
@@ -186,11 +188,14 @@ export class SettingsController {
? (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } })).revealUntil
: null,
fxStatus: this.fx.status(r.userId),
rates: await this.db.exchangeRate.findMany({
where: { userId: r.userId },
select: { currency: true, baseCurrency: true, date: true, rate: true, source: true },
orderBy: { date: 'desc' },
}),
rates: showRates
? await this.db.exchangeRate.findMany({
where: { userId: r.userId },
select: { currency: true, baseCurrency: true, date: true, rate: true, source: true },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
take: 100,
})
: [],
};
}
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
+129 -91
View File
@@ -4,6 +4,7 @@ import {
Post,
Body,
Req,
Query,
BadRequestException,
ConflictException,
} from '@nestjs/common';
@@ -13,109 +14,146 @@ import { Database } from './database';
import { UserRequest } from './auth';
import { transferInput, toBusinessDate } from './validation';
import { businessTime } from './calculation';
import { movementDeltas } from './movement';
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
@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' }],
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
const q = pageInput(query);
const rows = await this.db.$transaction(async (tx) => {
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
return tx.transfer.findMany({
where: { userId: r.userId, id: { in: ids.map((row) => row.id) } },
include: {
source: { select: { name: true, kind: true } },
target: { select: { name: true, kind: true } },
},
orderBy: [{ effectiveDate: 'desc' }, { id: 'desc' }],
});
});
return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({
const items = rows.slice(0, q.limit).map(({ userId, importedFromId, effectiveDate, ...v }) => ({
...v,
date: businessTime(effectiveDate),
}));
const last = rows[q.limit - 1];
return {
items,
nextCursor: rows.length > q.limit ? encodeCursor(last.effectiveDate, last.id) : null,
revealed: r.revealed,
};
}
@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 };
}
return this.db.serial(async (tx) => {
// Lock in a consistent order before reading balances or idempotency state.
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
if (v.requestId) {
const existing = await tx.transfer.findFirst({
where: { id: v.requestId, userId: r.userId },
});
if (existing) {
if (
existing.operation !== v.operation ||
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 },
},
}
const metadata = await tx.position.findMany({
where: {
id: { in: [v.sourceId, v.targetId] },
userId: r.userId,
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
const latest = await latestRevisions(
tx,
metadata.map((p) => p.id),
new Date('9999-01-01'),
);
const accounts = metadata.map((p) => ({
...p,
revisions: latest.filter((r) => r.positionId === p.id),
}));
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 (
source.kind !== 'account' ||
source.side !== 'asset' ||
(v.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const before = new Decimal(source.revisions[0].amount.toString());
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
if (sourceAfter.isNegative()) throw new BadRequestException('转出账户余额不足(含手续费)');
if (after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务');
if (after.gte('10000000000000000') || sourceAfter.gte('10000000000000000'))
throw new BadRequestException('变更后的金额超出支持范围');
const outgoing = await tx.revision.create({
data: {
positionId: source.id,
amount: sourceAfter.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.sourceReason,
},
});
const incoming = await tx.revision.create({
data: {
positionId: target.id,
amount: after.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.targetReason,
},
});
const row = await tx.transfer.create({
data: {
id: v.requestId,
userId: r.userId,
operation: v.operation,
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,
},
});
if (v.operation !== 'transfer')
await tx.positionLink.upsert({
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
create: { sourceId: target.id, targetId: source.id },
update: {},
});
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 },
);
return { id: row.id };
});
}
}
+26 -3
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import Decimal from 'decimal.js';
export const currencies = [
'CNY',
'USD',
@@ -64,6 +65,10 @@ export const revisionInput = z
'correction',
'transfer_out',
'transfer_in',
'loan_borrow',
'loan_lend',
'loan_collect',
'loan_repay',
])
.default('balance'),
})
@@ -90,7 +95,6 @@ export const positionInput = positionMeta
.superRefine((p, c) => {
if (
(p.kind === 'asset' && p.side !== 'asset') ||
(p.kind === 'debt' && p.side !== 'liability') ||
(p.kind === 'account' &&
['credit_card', 'loan'].includes(p.category) &&
p.side !== 'liability')
@@ -124,14 +128,33 @@ export const hiddenMenus = z
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
export const transferInput = z
.object({
operation: z.enum(['transfer', 'borrow', 'lend', 'collect', 'repay']).default('transfer'),
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'),
fee: z
.string()
.regex(
/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/,
'手续费可为负数表示优惠,最多 16 位整数和 8 位小数',
)
.default('0'),
date: businessDate,
notes,
})
.strict()
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账');
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账')
.refine((v) => new Decimal(v.fee).neg().lte(v.amount), {
message: '手续费优惠不能超过转出金额',
path: ['fee'],
});
export const pairedReasons = [
'transfer_out',
'transfer_in',
'loan_borrow',
'loan_lend',
'loan_collect',
'loan_repay',
];
+2 -2
View File
@@ -41,7 +41,7 @@ export function packBackup(b: Backup) {
contents['manifest.json'] = JSON.stringify(
{
format: 'worthpath',
version: 5,
version: 6,
exportedAt: b.exportedAt,
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
},
@@ -116,7 +116,7 @@ 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), z.literal(5)]),
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6)]),
exportedAt: z.iso.datetime(),
files: z
.array(
+24 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { history, overview, totals, type Holding, type Rate } from '../src/calculation';
import { history, overview, totals, trend, type Holding, type Rate } from '../src/calculation';
import { positionInput, date, amount, businessDate, toBusinessDate } from '../src/validation';
import { validateBackup } from '../src/backup';
import { RatesService } from '../src/rates';
@@ -44,6 +44,29 @@ const rate = (value: string, day: string): Rate => ({
rate: value,
source: 'manual',
});
test('bounded replay seeds the range, preserves same-time order, FX attribution and missing rates', () => {
const a = p('asset', 'USD');
a.revisions = [
{ ...rev('110', '2026-09-02'), sequence: 4 },
{ ...rev('100', '2026-08-31'), sequence: 1 },
{ ...rev('105', '2026-09-02'), sequence: 3 },
];
const rates = [rate('7', '2026-08-31'), rate('8', '2026-09-02')];
const rows = trend([a], rates, 'CNY', '2026-09-01', '2026-09-03');
assert.equal(rows[0].net, '700.00');
assert.equal(rows[1].fxChange, '100.00');
assert.equal(rows[1].balanceChange, '80.00');
assert.equal(rows[2].net, '880.00');
assert.equal('items' in rows[0], false);
const sampled = trend([a], rates, 'CNY', '2026-09-01', '2026-09-03', 'month');
assert.equal(sampled.length, 1);
assert.equal(sampled[0].fxChange, '100.00');
assert.equal(sampled[0].balanceChange, '80.00');
const missing = trend([a], [rate('8', '2026-09-02')], 'CNY', '2026-09-01', '2026-09-03');
assert.equal(missing[0].complete, false);
assert.equal(missing[1].fxChange, null);
assert.equal(missing[2].balanceChange, '0.00');
});
test('decimal totals and liability sign', () => {
const a = p(),
b = p('liability');
+173
View File
@@ -0,0 +1,173 @@
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, packBackup } 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!;
test('borrow/lend/collect/repay pair balances atomically and survive concurrency and restore', async () => {
const db = new PrismaClient(),
users: string[] = [];
async function user() {
const row = await db.user.create({
data: { username: 'wp_debt_' + randomUUID(), passwordHash: 'not-a-login-hash' },
});
users.push(row.id);
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId: row.id,
expiresAt: new Date(Date.now() + 600000),
},
});
return { id: row.id, token };
}
async function call(token: string, path: string, method = 'GET', body?: unknown) {
const response = await fetch(base + path, {
method,
headers: {
Cookie: 'wp_session=' + token,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: response.status,
data: response.headers.get('content-type')?.includes('application/zip')
? ((await readBackupZip(Buffer.from(await response.arrayBuffer()))) as any)
: await response.json(),
};
}
try {
const a = await user(),
b = await user();
async function position(name: string, kind: string, side: string, amount = '0') {
const result = await call(a.token, '/positions', 'POST', {
name,
kind,
side,
amount,
currency: 'CNY',
category: kind === 'debt' ? 'personal' : 'bank',
date: '2026-09-01T00:00',
});
assert.equal(result.status, 201);
return result.data.id as string;
}
const cash = await position('test account', 'account', 'asset', '1000'),
borrowed = await position('test borrowed', 'debt', 'liability'),
lent = await position('test lent', 'debt', 'asset');
const request = (operation: string, targetId: string, amount: string, fee = '0') => ({
requestId: randomUUID(),
operation,
sourceId: cash,
targetId,
amount,
received: amount,
fee,
date: '2026-09-02T10:00',
});
const borrow = request('borrow', borrowed, '100', '2');
const parallel = await Promise.all([
call(a.token, '/transfers', 'POST', borrow),
call(a.token, '/transfers', 'POST', borrow),
]);
assert.deepEqual(
parallel.map((r) => r.status),
[201, 201],
);
assert.equal((await call(a.token, '/overview')).data.net, '998.00');
assert.equal(
(await call(a.token, '/transfers', 'POST', request('lend', lent, '200', '-2'))).status,
201,
);
assert.equal((await call(a.token, '/overview')).data.net, '1000.00');
assert.equal(
(await call(a.token, '/transfers', 'POST', request('collect', lent, '50'))).status,
201,
);
assert.equal(
(await call(a.token, '/transfers', 'POST', request('repay', borrowed, '30', '1'))).status,
201,
);
assert.equal((await call(a.token, '/overview')).data.net, '999.00');
const list = (await call(a.token, '/positions')).data;
assert.equal(list.find((p: any) => p.id === cash).amount, '919');
assert.equal(list.find((p: any) => p.id === borrowed).amount, '70');
assert.equal(list.find((p: any) => p.id === lent).amount, '150');
assert.equal(list.find((p: any) => p.id === lent).outgoing[0].targetId, cash);
const count = await db.revision.count({ where: { position: { userId: a.id } } });
assert.equal(
(await call(a.token, '/transfers', 'POST', request('repay', borrowed, '71'))).status,
400,
);
assert.equal(
(await call(a.token, '/transfers', 'POST', request('lend', borrowed, '10'))).status,
400,
);
assert.equal(
(await call(b.token, '/transfers', 'POST', request('repay', borrowed, '10'))).status,
400,
);
assert.equal(await db.revision.count({ where: { position: { userId: a.id } } }), count);
const race = await Promise.all(
[1, 2].map(() => call(a.token, '/transfers', 'POST', request('collect', lent, '100'))),
);
assert.equal(race.filter((r) => r.status === 201).length, 1, JSON.stringify(race));
assert.equal(race.filter((r) => r.status === 400 || r.status === 409).length, 1);
const paired = (await call(a.token, '/positions/' + cash + '/history')).data.items.find(
(r: any) => r.reason === 'loan_collect',
);
assert.equal(
(
await call(a.token, '/positions/' + cash + '/revisions/' + paired.id, 'PUT', {
amount: '1',
date: '2026-09-02T10:00',
})
).status,
409,
);
// A later record at the same business minute can be corrected by sequence.
assert.equal(
(
await call(a.token, '/positions/' + cash + '/revisions', 'POST', {
amount: '1020',
date: '2026-09-02T10:00',
})
).status,
201,
);
const latest = (await call(a.token, '/positions/' + cash + '/history?limit=1')).data.items[0];
assert.equal(
(
await call(a.token, '/positions/' + cash + '/revisions/' + latest.id, 'PUT', {
amount: '1021',
date: '2026-09-02T10:00',
})
).status,
200,
);
const backup = (await call(a.token, '/backup')).data;
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 6);
assert.equal(backup.transfers.length, 5);
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
assert.equal(
(await call(b.token, '/backup/import', 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal(
(await call(b.token, '/overview')).data.net,
(await call(a.token, '/overview')).data.net,
);
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 5);
const restored = (await call(b.token, '/positions?kind=debt')).data;
assert.equal(restored.find((p: any) => p.side === 'asset').amount, '50');
assert.equal(restored.find((p: any) => p.side === 'liability').amount, '70');
} finally {
await db.user.deleteMany({ where: { id: { in: users } } });
await db.$disconnect();
}
});
+15 -5
View File
@@ -176,7 +176,11 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
).status,
201,
);
const d = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data;
const d = {
history: (
await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)
).data.items.reverse(),
};
assert.equal(
(
await call(
@@ -200,7 +204,8 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
200,
);
assert.equal(
(await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history[1].delta,
(await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)).data.items[0]
.delta,
'20',
);
assert.equal(
@@ -249,7 +254,9 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
).status,
201,
);
const sameDay = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history;
const sameDay = (
await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)
).data.items.reverse();
assert.equal(sameDay.length, 3);
assert.equal(sameDay[2].after, '120.1');
assert.equal(sameDay[2].delta, '10');
@@ -305,9 +312,12 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
),
);
assert.equal(restored.find((p: { kind: string }) => p.kind === 'debt').outgoing.length, 2);
const restoredHistory = restored.find(
const restoredAccount = restored.find(
(p: { kind: string; side: string }) => p.kind === 'account' && p.side === 'asset',
).history;
);
const restoredHistory = (
await call('/positions/' + restoredAccount.id + '/history', 'GET', undefined, b.cookie)
).data.items.reverse();
assert.deepEqual(
restoredHistory.map((h: { after: string }) => h.after),
sameDay.map((h: { after: string }) => h.after),
+3 -1
View File
@@ -109,7 +109,9 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
).status,
201,
);
const history = (await call('/positions/' + hidden, 'GET', undefined, a.cookie)).data.history;
const history = (
await call('/positions/' + hidden + '/history', 'GET', undefined, a.cookie)
).data.items.reverse();
assert.deepEqual(
history.map((h: { time: string }) => h.time),
['2026-09-01T09:17', '2026-09-01T09:18'],
+196
View File
@@ -0,0 +1,196 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PrismaClient } from '@prisma/client';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { toBusinessDate } from '../src/validation';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('bounded APIs preserve seeds, predecessor balances, pagination, archive and FX scope', async () => {
const db = new PrismaClient();
const user = await db.user.create({
data: { username: 'wp_query_' + randomUUID(), passwordHash: 'not-a-login-hash' },
});
const token = randomBytes(32).toString('hex'),
sessionId = createHash('sha256').update(token).digest('hex');
let restoredUserId: string | undefined;
async function call(path: string) {
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
return { status: response.status, data: await response.json() };
}
try {
await db.session.create({
data: { id: sessionId, userId: user.id, expiresAt: new Date(Date.now() + 600000) },
});
const make = (name: string, currency = 'CNY', hidden = false) =>
db.position.create({
data: {
userId: user.id,
name,
currency,
hidden,
kind: 'account',
side: 'asset',
category: 'bank',
notes: '',
},
});
const a = await make('archive'),
b = await make('foreign', 'USD'),
hidden = await make('hidden', 'CNY', true);
const revision = (positionId: string, amount: string, date: string) =>
db.revision.create({
data: {
positionId,
amount,
effectiveDate: toBusinessDate(date),
notes: '',
reason: 'balance',
},
});
await revision(a.id, '100', '2026-08-31T23:59');
await revision(a.id, '119', '2026-09-02T00:01');
await revision(a.id, '120', '2026-09-02T00:01');
await revision(a.id, '110', '2026-09-01T00:01'); // Late insert does not replace the latest balance.
await revision(b.id, '10', '2026-08-31T23:59');
await revision(hidden.id, '3', '2026-09-01T00:01');
await db.position.update({ where: { id: a.id }, data: { archived: true } });
await db.exchangeRate.create({
data: {
userId: user.id,
currency: 'USD',
baseCurrency: 'CNY',
date: new Date('2026-09-02'),
rate: '7',
source: 'manual',
},
});
const list = (await call('/positions?kind=account')).data;
assert.equal(list.length, 2);
assert.equal(list.find((p: any) => p.id === a.id).amount, '120');
assert.equal('history' in list[0], false);
assert.equal('revisions' in list[0], false);
const current = (await call('/overview')).data;
assert.equal(current.net, '190.00');
assert.equal('trend' in current, false);
const trend = (await call('/trend?from=2026-09-01&to=2026-09-03')).data.items;
assert.equal(trend[0].complete, false);
assert.equal(trend[1].net, '190.00');
assert.equal(trend[1].balanceChange, null);
assert.equal(trend[2].fxChange, '0.00');
assert.equal('items' in trend[0], false);
const seen = new Set<string>();
let cursor: string | null = null;
do {
const response = await call(
'/positions/' + a.id + '/history?limit=1' + (cursor ? '&cursor=' + cursor : ''),
);
assert.equal(response.status, 200);
const row = response.data.items[0];
assert.equal(seen.has(row.id), false);
seen.add(row.id);
if (row.after === '120') {
assert.equal(row.before, '119');
assert.equal(row.delta, '1');
}
if (row.after === '119') {
assert.equal(row.before, '110');
assert.equal(row.delta, '9');
}
if (row.after === '110') assert.equal(row.before, '100');
cursor = response.data.nextCursor;
} while (cursor);
assert.equal(seen.size, 4);
const bounded = (await call('/positions/' + a.id + '/history?from=2026-09-02&to=2026-09-02'))
.data.items;
assert.equal(bounded[1].before, '110');
assert.equal((await call('/history?limit=101')).status, 400);
assert.equal((await call('/history?cursor=bad')).status, 400);
assert.equal((await call('/trend?from=2020-01-01&to=2026-09-03')).status, 400);
assert.equal((await call('/positions/' + hidden.id + '/history')).status, 404);
await db.session.update({
where: { id: sessionId },
data: { revealUntil: new Date(Date.now() + 60000) },
});
assert.equal((await call('/overview')).data.net, '193.00');
await db.user.update({ where: { id: user.id }, data: { baseCurrency: 'USD' } });
assert.equal((await call('/overview')).data.complete, false);
await db.exchangeRate.create({
data: {
userId: user.id,
currency: 'CNY',
baseCurrency: 'USD',
date: new Date('2026-09-01'),
rate: '0.1',
source: 'manual',
},
});
assert.equal((await call('/overview')).data.net, '22.30');
await db.user.update({ where: { id: user.id }, data: { baseCurrency: 'CNY' } });
await db.position.update({ where: { id: a.id }, data: { archived: false } });
const target = await make('discount target');
await revision(target.id, '0', '2026-09-01T00:00');
const origin =
process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
const transfer = {
requestId: randomUUID(),
sourceId: a.id,
targetId: target.id,
amount: '100',
received: '100',
fee: '-2',
date: '2026-09-03T00:01',
};
const post = async (path: string, body: unknown, cookie = token) => {
const response = await fetch(base + path, {
method: 'POST',
headers: {
Cookie: 'wp_session=' + cookie,
Origin: origin,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
return { status: response.status, data: await response.json() };
};
assert.equal((await post('/transfers', transfer)).status, 201);
assert.equal((await post('/transfers', transfer)).status, 201);
const discounted = (await call('/positions')).data;
assert.equal(discounted.find((p: any) => p.id === a.id).amount, '22');
assert.equal(discounted.find((p: any) => p.id === target.id).amount, '100');
assert.equal(
(await post('/transfers', { ...transfer, requestId: randomUUID(), fee: '-101' })).status,
400,
);
const exported = await fetch(base + '/backup', { headers: { Cookie: 'wp_session=' + token } });
assert.equal(exported.status, 200);
const backup = (await readBackupZip(Buffer.from(await exported.arrayBuffer()))) as any;
assert.equal(backup.transfers[0].fee, '-2');
const restored = await db.user.create({
data: { username: 'wp_query_restore_' + randomUUID(), passwordHash: 'not-a-login-hash' },
});
restoredUserId = restored.id;
const restoredToken = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(restoredToken).digest('hex'),
userId: restored.id,
expiresAt: new Date(Date.now() + 600000),
},
});
assert.equal(
(await post('/backup/import', { confirmed: true, backup }, restoredToken)).status,
201,
);
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
assert.equal(restoredTransfer.fee.toString(), '-2');
const restoredBalance = await db.revision.findUniqueOrThrow({
where: { id: restoredTransfer.sourceRevisionId },
});
assert.equal(restoredBalance.amount.toString(), '22');
} finally {
if (restoredUserId) await db.user.delete({ where: { id: restoredUserId } });
await db.user.delete({ where: { id: user.id } });
await db.$disconnect();
}
});
+8 -4
View File
@@ -103,8 +103,8 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
assert.equal(rows.find((p: any) => p.id === sourceId).amount, '74');
assert.equal(rows.find((p: any) => p.id === targetId).amount, '35');
assert.equal((await call('/overview', a.cookie)).data.net, '109.00');
assert.equal((await call('/transfers', b.cookie)).data.length, 0);
const revision = rows.find((p: any) => p.id === sourceId).history.at(-1);
assert.equal((await call('/transfers', b.cookie)).data.items.length, 0);
const revision = (await call('/positions/' + sourceId + '/history', a.cookie)).data.items[0];
assert.equal(
(
await call(`/positions/${sourceId}/revisions/${revision.id}`, a.cookie, 'PUT', {
@@ -188,7 +188,11 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
}),
),
);
assert.equal(race.filter((r) => r.status === 201).length, 1);
assert.equal(
race.filter((r) => r.status === 201).length,
1,
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
);
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
assert.equal(
(
@@ -249,7 +253,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
category: 'bank',
hidden: true,
});
const visibleTransfers = (await call('/transfers', a.cookie)).data;
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
assert.equal(visibleTransfers.length, 2);
assert.ok(
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),