perf: optimize portfolio queries and workflows
This commit is contained in:
1 parent
da98d02661
commit
a7e0a0fe58
45 files changed
+4446
-637
No files matched your search
@@ -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 = '资金往来及账户与债务的双边配对记账';
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user