From a7e0a0fe58e0ae8a83cf9db5004109deac57006f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Fri, 2 Oct 2026 12:44:07 +0800 Subject: [PATCH] perf: optimize portfolio queries and workflows --- README.md | 6 +- apps/api/package.json | 5 +- .../migration.sql | 6 + .../migration.sql | 3 + .../migration.sql | 1 + .../migration.sql | 97 ++ apps/api/prisma/schema.prisma | 87 +- apps/api/scripts/performance.ts | 253 ++++ apps/api/src/backup.ts | 16 +- apps/api/src/calculation.ts | 76 ++ apps/api/src/database.ts | 19 +- apps/api/src/movement.ts | 23 + apps/api/src/openapi.ts | 43 + apps/api/src/portfolio.ts | 293 +++-- apps/api/src/queries.ts | 288 +++++ apps/api/src/rates.ts | 17 +- apps/api/src/transfers.ts | 220 ++-- apps/api/src/validation.ts | 29 +- apps/api/src/zip.ts | 4 +- apps/api/test/calculation.test.ts | 25 +- apps/api/test/debts.test.ts | 173 +++ apps/api/test/integration.test.ts | 20 +- apps/api/test/privacy.test.ts | 4 +- apps/api/test/queries.test.ts | 196 +++ apps/api/test/transfers.test.ts | 12 +- apps/web/package.json | 10 +- apps/web/scripts/generate-locales.cjs | 11 + apps/web/src/App.tsx | 1123 ++++++++++++----- apps/web/src/DebtPaymentForm.tsx | 160 +++ apps/web/src/IconLibrary.tsx | 66 +- apps/web/src/IconPicker.tsx | 13 +- apps/web/src/TransferForm.tsx | 41 +- apps/web/src/api.ts | 15 +- apps/web/src/i18n.tsx | 89 ++ apps/web/src/locales/en.json | 360 ++++++ apps/web/src/locales/zh-Hant.json | 360 ++++++ apps/web/src/style.css | 26 + apps/web/test/i18n.test.ts | 22 + apps/web/tsconfig.json | 1 + docs/architecture.md | 2 + docs/database-comments.json | 405 ++++++ docs/performance-final.json | 366 ++++++ docs/performance.md | 73 ++ docs/transfers-plan.json | 16 + pnpm-lock.yaml | 8 + 45 files changed, 4446 insertions(+), 637 deletions(-) create mode 100644 apps/api/prisma/migrations/20261002000000_query_indexes/migration.sql create mode 100644 apps/api/prisma/migrations/20261002000100_remove_global_revision_index/migration.sql create mode 100644 apps/api/prisma/migrations/20261002000200_debt_movements/migration.sql create mode 100644 apps/api/prisma/migrations/20261002000300_chinese_comments/migration.sql create mode 100644 apps/api/scripts/performance.ts create mode 100644 apps/api/src/movement.ts create mode 100644 apps/api/src/queries.ts create mode 100644 apps/api/test/debts.test.ts create mode 100644 apps/api/test/queries.test.ts create mode 100644 apps/web/scripts/generate-locales.cjs create mode 100644 apps/web/src/DebtPaymentForm.tsx create mode 100644 apps/web/src/i18n.tsx create mode 100644 apps/web/src/locales/en.json create mode 100644 apps/web/src/locales/zh-Hant.json create mode 100644 apps/web/test/i18n.test.ts create mode 100644 docs/database-comments.json create mode 100644 docs/performance-final.json create mode 100644 docs/performance.md create mode 100644 docs/transfers-plan.json diff --git a/README.md b/README.md index ff23a15..6c30f55 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,11 @@ ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对 ## 账户转账与显示设置 -账户页面提供“账户间转账”,适用于自己启用中的资产账户。双方余额和历史在同一数据库事务中更新;手续费按转出币种额外扣除,同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账。转账时间不能早于双方最新余额;后续余额调整不能插入已有转账之前。负债还款继续使用负债余额更新流程。 +账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。 + +债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。ZIP v6 保留操作类型,兼容旧 v3/v4/v5 ZIP 和 v1/v2 JSON。 + +支持简中、English、繁中,在登录页或顶部切换,语言保存在当前浏览器。用户名称和备注保留原文。账户/资产/债务按页查询当前余额,历史和往来使用最多 100 条的游标分页;趋势默认最近 90 天,支持日、周、月。查询实现、迁移步骤、实测性能和验证边界见 [性能与联动验收](docs/performance.md)。 设置分类使用下拉菜单;资产总览、账户、独立资产、债务、变化记录分别使用显示开关,设置入口始终保留。顶部重复导航已移除。“显示备注”关闭后隐藏列表、详情与表单备注,原有备注不删除,备份仍完整包含。图标通过弹窗检索、选择或上传,透明背景显示;接近纯白的背景像素会转为透明,彩色背景需上传透明 PNG。 diff --git a/apps/api/package.json b/apps/api/package.json index 10d5294..9ade81d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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": { diff --git a/apps/api/prisma/migrations/20261002000000_query_indexes/migration.sql b/apps/api/prisma/migrations/20261002000000_query_indexes/migration.sql new file mode 100644 index 0000000..400d888 --- /dev/null +++ b/apps/api/prisma/migrations/20261002000000_query_indexes/migration.sql @@ -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`; diff --git a/apps/api/prisma/migrations/20261002000100_remove_global_revision_index/migration.sql b/apps/api/prisma/migrations/20261002000100_remove_global_revision_index/migration.sql new file mode 100644 index 0000000..8a4aea9 --- /dev/null +++ b/apps/api/prisma/migrations/20261002000100_remove_global_revision_index/migration.sql @@ -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`; diff --git a/apps/api/prisma/migrations/20261002000200_debt_movements/migration.sql b/apps/api/prisma/migrations/20261002000200_debt_movements/migration.sql new file mode 100644 index 0000000..3c9aad3 --- /dev/null +++ b/apps/api/prisma/migrations/20261002000200_debt_movements/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `Transfer` ADD COLUMN `operation` VARCHAR(16) NOT NULL DEFAULT 'transfer'; diff --git a/apps/api/prisma/migrations/20261002000300_chinese_comments/migration.sql b/apps/api/prisma/migrations/20261002000300_chinese_comments/migration.sql new file mode 100644 index 0000000..a62078b --- /dev/null +++ b/apps/api/prisma/migrations/20261002000300_chinese_comments/migration.sql @@ -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 = '资金往来及账户与债务的双边配对记账'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 001b411..37d564c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) } diff --git a/apps/api/scripts/performance.ts b/apps/api/scripts/performance.ts new file mode 100644 index 0000000..c53580d --- /dev/null +++ b/apps/api/scripts/performance.ts @@ -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) { + 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()); diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 78532c8..9491119 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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), diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index a5b2281..75d408d 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -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(); + 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, '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; +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 5557234..276fcb5 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -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(work: (tx: Prisma.TransactionClient) => Promise): Promise { + 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(); } diff --git a/apps/api/src/movement.ts b/apps/api/src/movement.ts new file mode 100644 index 0000000..148cf99 --- /dev/null +++ b/apps/api/src/movement.ts @@ -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, + }; +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index a68e69e..a1ed3b7 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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]; diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 0d69359..2df51c8 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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, + ) { + 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, + }; + }); } } diff --git a/apps/api/src/queries.ts b/apps/api/src/queries.ts new file mode 100644 index 0000000..8abea6e --- /dev/null +++ b/apps/api/src/queries.ts @@ -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, +) { + // 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(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 => 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(); + 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, + }; +} diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 6d6ff66..2e83bdc 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -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) { diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts index 1d7f956..bcee4a0 100644 --- a/apps/api/src/transfers.ts +++ b/apps/api/src/transfers.ts @@ -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 }; + }); } } diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index a3a653f..772b95f 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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', +]; diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index cdb1ac8..e333bdf 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -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 { 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( diff --git a/apps/api/test/calculation.test.ts b/apps/api/test/calculation.test.ts index b1d3f30..ccb6290 100644 --- a/apps/api/test/calculation.test.ts +++ b/apps/api/test/calculation.test.ts @@ -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'); diff --git a/apps/api/test/debts.test.ts b/apps/api/test/debts.test.ts new file mode 100644 index 0000000..d9cd2ce --- /dev/null +++ b/apps/api/test/debts.test.ts @@ -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(); + } +}); diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index 1d31916..dc29201 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -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), diff --git a/apps/api/test/privacy.test.ts b/apps/api/test/privacy.test.ts index e8bd812..a01c21d 100644 --- a/apps/api/test/privacy.test.ts +++ b/apps/api/test/privacy.test.ts @@ -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'], diff --git a/apps/api/test/queries.test.ts b/apps/api/test/queries.test.ts new file mode 100644 index 0000000..bd6e67d --- /dev/null +++ b/apps/api/test/queries.test.ts @@ -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(); + 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(); + } +}); diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index 71aee05..0e9e483 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -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), diff --git a/apps/web/package.json b/apps/web/package.json index 996ebcc..a29e01b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,20 +3,22 @@ "private": true, "type": "module", "scripts": { + "locales:generate": "node scripts/generate-locales.cjs", "dev": "vite --host 0.0.0.0", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit" }, "dependencies": { + "lucide-react": "^0.468.0", "react": "^19.1.0", - "react-dom": "^19.1.0", - "lucide-react": "^0.468.0" + "react-dom": "^19.1.0" }, "devDependencies": { "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^5.0.0", + "opencc-js": "^1.4.2", "typescript": "^5.9.0", - "vite": "^7.1.0", - "@vitejs/plugin-react": "^5.0.0" + "vite": "^7.1.0" } } diff --git a/apps/web/scripts/generate-locales.cjs b/apps/web/scripts/generate-locales.cjs new file mode 100644 index 0000000..ba54121 --- /dev/null +++ b/apps/web/scripts/generate-locales.cjs @@ -0,0 +1,11 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const OpenCC = require('opencc-js'); +const folder = path.join(__dirname, '../src/locales'); +const english = JSON.parse(fs.readFileSync(path.join(folder, 'en.json'), 'utf8')); +const convert = OpenCC.Converter({ from: 'cn', to: 'tw' }); +const traditional = Object.fromEntries( + Object.keys(english).map((key) => [key, key === '确定清空' ? key : convert(key)]), +); +fs.writeFileSync(path.join(folder, 'zh-Hant.json'), JSON.stringify(traditional, null, 2) + '\n'); +console.log(`Generated ${Object.keys(traditional).length} Traditional Chinese phrases.`); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8f4ecc7..c58e6a4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,4 @@ +import { t as tr, useLocale, LanguagePicker } from './i18n'; import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react'; import { LayoutDashboard, @@ -31,10 +32,12 @@ import { type History, type Rate, type Total, + type PageResult, } from './api'; import './style.css'; import { IconPicker } from './IconPicker'; import { TransferForm } from './TransferForm'; +import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; import type { Transfer } from './api'; import { IconLibrary, iconUrl } from './IconLibrary'; const labels: Record = { @@ -68,7 +71,7 @@ const categories: Record = { ], }; const categoryLabel = (p: Position) => - categories[p.kind]?.find(([v]) => v === p.category)?.[1] || p.category; + tr(categories[p.kind]?.find(([v]) => v === p.category)?.[1] || p.category); function Currency({ value, onChange }: { value: string; onChange: (v: string) => void }) { return ( - +

- {register ? '已有账号?' : '还没有账号?'}{' '} + {register ? tr('已有账号?') : tr('还没有账号?')}{' '}

- 个人数据按登录身份隔离 + {tr('个人数据按登录身份隔离')}

@@ -533,7 +735,7 @@ export default function App() {
W WorthPath
-

我的资产空间

+

{tr('我的资产空间')}

@@ -553,11 +755,14 @@ export default function App() {
{user.username.slice(0, 1).toUpperCase()}
{user.username} - 个人账户 · {user.baseCurrency} + + {tr('个人账户 ·')} + {user.baseCurrency} +
@@ -605,22 +817,22 @@ export default function App() {

WORTHPATH / {today()}

{p?.iconId && ( - 账户图标 + {tr('账户图标')} )} - {p ? p.name : labels[page]} + {p ? p.name : tr(labels[page])}

{page === 'overview' - ? '你的财富全貌,从这里开始。' + ? tr('你的财富全貌,从这里开始。') : page === 'settings' - ? '让数据始终可控,让换算清晰可靠。' - : '保留原币金额,追踪长期变化。'} + ? tr('让数据始终可控,让换算清晰可靠。') + : tr('保留原币金额,追踪长期变化。')}

{page === 'account' && !p && ( )} {['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction} @@ -630,55 +842,92 @@ export default function App() {
{error}
)} {success && (
- {success} + {tr(success)}
)} {loading && (

- 正在刷新数据… + {tr('正在刷新数据…')}

)} + {page === 'overview' && ( +
+ {' '} +
+ + + +
+

+ {tr( + '默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。', + )} +

+
+ )} {page === 'overview' && overview && ( <> {!overview.complete && (
- 缺少 {overview.missing.join('、')} 到 {user.baseCurrency}{' '} - 的汇率。以下仅展示已能换算的部分,不能作为完整总额。 + {tr('缺少')} + {overview.missing.join('、')} {tr('到')} + {user.baseCurrency} {tr('的汇率。以下仅展示已能换算的部分,不能作为完整总额。')}
)}
{[ - ['总资产', overview.assets, 'asset'], - ['总负债', overview.liabilities, 'debt'], - ['净资产', overview.net, 'net'], + [tr('总资产'), overview.assets, 'asset'], + [tr('总负债'), overview.liabilities, 'debt'], + [tr('净资产'), overview.net, 'net'], ].map(([title, value, cls]) => (
{title} - {!overview.complete ? '(部分)' : ''} + {!overview.complete ? tr('(部分)') : ''}
{money(value, user.baseCurrency)} - {title === '净资产' - ? '总资产 − 总负债' - : title === '总资产' - ? '账户资产 + 独立资产' - : '负债账户 + 独立债务'} + {title === tr('净资产') + ? tr('总资产 − 总负债') + : title === tr('总资产') + ? tr('账户资产 + 独立资产') + : tr('负债账户 + 独立债务')}
))} @@ -686,15 +935,17 @@ export default function App() {
-

净资产轨迹

- 按业务日期 +

{tr('净资产轨迹')}

+ {tr('按业务日期')}
-

按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。

+

+ {tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')} +

-

资产与负债组成

- {positions.length ? ( +

{tr('资产与负债组成')}

+ {overview.items.length ? ( ['account', 'asset', 'debt'].map((k) => ( )) ) : ( )}

- 关联关系不会重复计入总额 + {tr('关联关系不会重复计入总额')}

-

最近变化

+

{tr('最近变化')}

{ setSelected(id); - setPage(positions.find((p) => p.id === id)?.kind || 'account'); + setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account'); }} />
- 本位币 {user.baseCurrency} · 汇率日期: + {tr('本位币')} + {user.baseCurrency} {tr('· 汇率日期:')} {[ ...new Set( overview.items .filter((i) => i.currency !== user.baseCurrency && i.rateDate) .map((i) => i.rateDate), ), - ].join(' / ') || '尚无外币汇率'}{' '} - · 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。 + ].join(' / ') || tr('尚无外币汇率')}{' '} + {tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
{overview.trend.length > 1 && (
-

变化归因

+

{tr('变化归因')}

- - - - + + + + @@ -777,7 +1031,9 @@ export default function App() { ? '—' : money(r.fxChange, user.baseCurrency)} - + ))} @@ -792,31 +1048,77 @@ export default function App() { <>
- {categoryLabel(p)} · {p.side === 'asset' ? '资产' : '负债'} - {p.archived ? ' · 已归档' : ''} + {categoryLabel(p)} ·{' '} + {p.kind === 'debt' + ? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)') + : p.side === 'asset' + ? tr('资产') + : tr('负债')} + {p.archived ? tr(' · 已归档') : ''}

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

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

- 本位币: - {overview?.items.find((i) => i.id === p.id)?.converted !== null - ? money( - overview?.items.find((i) => i.id === p.id)?.converted || '0', - user.baseCurrency, - ) - : '缺少汇率'} + {tr('本位币:')} + {p.converted !== null + ? money(p.converted || '0', user.baseCurrency) + : tr('缺少汇率')}

-

{p.notes || '暂无备注'}

- 币种和资产负债属性创建后固定。归档项目仍计入总览。 +

{p.notes || tr('暂无备注')}

+ {tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}
+ {p.kind === 'account' && p.side === 'asset' && !p.archived && ( + <> + + + + )} + {p.kind === 'debt' && !p.archived && ( + <> + + + + )}
{p.kind === 'debt' && (
-

关联资产与账户

+

{tr('关联资产与账户')}

-

只展示关系,不改变资产或债务的统计。

+

{tr('只展示关系,不改变资产或债务的统计。')}

{p.outgoing.length ? ( p.outgoing.map((l) => ( )) ) : ( -

尚未关联

+

{tr('尚未关联')}

)}
)}
-

余额与估值历史

+

{tr('余额与估值历史')}

- 每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。 + {tr( + '每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。', + )}

setModal({ kind: 'correction', p, h })} /> + {historyCursor && ( + + )}
) : ( @@ -899,7 +1209,7 @@ export default function App() {
{p.iconId ? ( - 账户图标 + {tr('账户图标')} ) : p.kind === 'asset' ? ( ) : p.kind === 'debt' ? ( @@ -912,26 +1222,28 @@ export default function App() {

{p.name}

- {categoryLabel(p)} · {p.side === 'asset' ? '资产' : '负债'} - {p.archived ? ' · 已归档' : ''} + {categoryLabel(p)} ·{' '} + {p.kind === 'debt' + ? tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)') + : p.side === 'asset' + ? tr('资产') + : tr('负债')} + {p.archived ? tr(' · 已归档') : ''}

{money(p.amount, p.currency)} - 折合{' '} - {overview?.items.find((i) => i.id === p.id)?.converted === null - ? '缺少汇率' - : money( - overview?.items.find((i) => i.id === p.id)?.converted || '0', - user.baseCurrency, - )} + {tr('折合')}{' '} + {p.converted === null + ? tr('缺少汇率') + : money(p.converted || '0', user.baseCurrency)} ))} ) : ( )} @@ -939,56 +1251,107 @@ export default function App() { ))} {page === 'history' && (
-

账户转账

+
+ + +
+

{tr('资金往来')}

{transfers.length ? (
{transfers.map((t) => (
- {t.source.name} → {t.target.name} + {operationLabel(t.operation || 'transfer')} · + + {' ↔ '} + {displayTime(t.date)}

{money(t.amount, t.sourceCurrency)} → {money(t.received, t.targetCurrency)}{' '} - · 手续费 {money(t.fee, t.sourceCurrency)} + {tr('· 手续费')} + {money(t.fee, t.sourceCurrency)}

{t.notes &&

{t.notes}

}
))}
) : ( -

暂无账户间转账记录。

+

{tr('暂无账户间转账记录。')}

)} -

全部金额变化

-

原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。

+ {transferCursor && ( + + )} +

{tr('全部金额变化')}

+

+ {tr('原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。')} +

p.history) - .sort((a, b) => b.time.localeCompare(a.time) || b.sequence - a.sequence)} + rows={historyRows} open={(id) => { setSelected(id); - setPage(positions.find((p) => p.id === id)?.kind || 'account'); + setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account'); }} /> + {historyCursor && ( + + )}
)} {page === 'settings' && ( <>
@@ -1001,14 +1364,16 @@ export default function App() {
日期余额 / 估值变化汇率变化净资产{tr('日期')}{tr('余额 / 估值变化')}{tr('汇率变化')}{tr('净资产')}
{r.complete ? money(r.net, user.baseCurrency) : '汇率不完整'} + {r.complete ? money(r.net, user.baseCurrency) : tr('汇率不完整')} +
- - - - + + + + @@ -1156,20 +1527,25 @@ export default function App() { - + ))}
原币 → 本位币汇率日期来源{tr('原币 → 本位币')}{tr('汇率')}{tr('日期')}{tr('来源')}
{r.rate} {r.date.slice(0, 10)}{r.source === 'manual' ? '历史导入' : 'Frankfurter'}{r.source === 'manual' ? tr('历史导入') : 'Frankfurter'}
) : ( - + )}
)} -
WorthPath · 记录你的积累,理解你的财富
+
{tr('WorthPath · 记录你的积累,理解你的财富')}
{clearConfirmation > 0 && ( { if (!busy) setClearConfirmation(0); @@ -1332,10 +1722,10 @@ export default function App() { >

{clearConfirmation === 1 - ? '将清空本账号全部财务数据及私有图标,请确认备份已保存。' - : '这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。'} + ? tr('将清空本账号全部财务数据及私有图标,请确认备份已保存。') + : tr('这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。')}

-

登录账号、个人设置和已发布的共享图标会保留。

+

{tr('登录账号、个人设置和已发布的共享图标会保留。')}

{clearConfirmation === 1 ? ( <> @@ -1345,10 +1735,10 @@ export default function App() { disabled={busy} onClick={() => setClearConfirmation(0)} > - 取消清空 + {tr('取消清空')} ) : ( @@ -1358,16 +1748,16 @@ export default function App() { disabled={busy} onClick={() => void act(async () => { - await api('/backup/clear', 'POST', { confirmation: '确定清空' }); + await api('/backup/clear', 'POST', { confirmation: tr('确定清空') }); setClearConfirmation(0); setClearStep(0); setSelected(null); setBackup(null); setPreview(null); - }, '本账号数据已清空') + }, tr('本账号数据已清空')) } > - {busy ? '正在清空…' : '最终确认清空'} + {busy ? tr('正在清空…') : tr('最终确认清空')} )} @@ -1390,34 +1780,67 @@ export default function App() { {modal && ( { if (!busy) setModal(null); }} > - {modal.kind === 'transfer' ? ( - p.kind === 'account' && p.side === 'asset' && !p.archived, - )} - busy={busy} - submit={(v) => - void act(() => api('/transfers', 'POST', v), '转账已保存,双方余额已更新') - } - /> + {['transfer', 'debt-payment'].includes(modal.kind) ? ( + movementLoading ? ( +

{tr('正在刷新数据…')}

+ ) : modal.kind === 'transfer' ? ( + p.kind === 'account' && p.side === 'asset' && !p.archived, + )} + initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined} + busy={busy} + submit={(v) => + void act(() => api('/transfers', 'POST', v), tr('转账已保存,双方余额已更新')) + } + /> + ) : ( + p.kind === 'account' && p.side === 'asset' && !p.archived, + )} + debts={movementPositions.filter((p) => p.kind === 'debt' && !p.archived)} + initialAccount={ + modal.p?.kind === 'account' + ? modal.p.id + : modal.p?.outgoing.find((link) => + movementPositions.some( + (p) => + p.id === link.targetId && + p.kind === 'account' && + p.side === 'asset' && + !p.archived, + ), + )?.targetId + } + initialDebt={modal.p?.kind === 'debt' ? modal.p.id : undefined} + initialOperation={modal.operation} + busy={busy} + submit={(v) => + void act(() => api('/transfers', 'POST', v), tr('账户与债务已同步更新')) + } + /> + ) ) : (
{ @@ -1429,7 +1852,7 @@ export default function App() { if (kind === 'reveal') { void act( () => api('/auth/reveal', 'POST', { password: f.get('password') }), - '隐藏项目已解锁 5 分钟', + tr('隐藏项目已解锁 5 分钟'), ); } else if (kind === 'links') { void act( @@ -1437,7 +1860,7 @@ export default function App() { api('/positions/' + mp!.id + '/links', 'PUT', { targetIds: f.getAll('targetIds'), }), - '关联已更新', + tr('关联已更新'), ); } else if (kind === 'revision' || kind === 'correction') { void act( @@ -1447,7 +1870,7 @@ export default function App() { modal.h ? 'PUT' : 'POST', v, ), - '金额历史已保存,总览已重新计算', + tr('金额历史已保存,总览已重新计算'), ); } else if (kind === 'edit') { void act( @@ -1458,15 +1881,16 @@ export default function App() { archived: mp!.archived, hidden: f.get('hidden') === 'on', }), - '项目资料已保存', + tr('项目资料已保存'), ); } else { const category = String(v.category), side = - kind === 'debt' || - (kind === 'account' && ['credit_card', 'loan'].includes(category)) + kind === 'account' && ['credit_card', 'loan'].includes(category) ? 'liability' - : 'asset'; + : kind === 'debt' + ? String(v.side) + : 'asset'; void act( () => api('/positions', 'POST', { @@ -1476,13 +1900,13 @@ export default function App() { side, hidden: f.get('hidden') === 'on', }), - '项目已添加', + tr('项目已添加'), ); } }} > {modal.kind === 'reveal' && ( - + {(modal.kind === 'account' || modal.p?.kind === 'account') && ( )} - + - + {modal.kind === 'account' && ( -

信用卡、贷款账户自动计入负债,其余账户计入资产。

+

+ {tr('信用卡、贷款账户自动计入负债,其余账户计入资产。')} +

)} )} + {modal.kind === 'debt' && ( + <> + + + +

+ {tr( + '已有债务填写剩余本金;新借入或借出可先填0,保存后通过账户联动操作记录资金。', + )} +

+ + )} {['account', 'asset', 'debt'].includes(modal.kind) && ( - + )} {['account', 'asset', 'debt', 'revision', 'correction'].includes(modal.kind) && ( - + - +

- 填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。 + {tr( + '填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。', + )}

)} {modal.kind === 'correction' && (

- 这将修改选中的历史金额并重算后续变化,请确认日期和余额。 + {tr('这将修改选中的历史金额并重算后续变化,请确认日期和余额。')}

)} {modal.kind === 'links' ? (
- {positions.filter((p) => p.kind !== 'debt').length ? ( - positions - .filter((p) => p.kind !== 'debt') - .map((p) => ( - - )) + {linkOptions.length ? ( + linkOptions.map((p) => ( + + )) ) : ( -

请先创建账户或独立资产。

+

{tr('请先创建账户或独立资产。')}

)}
) : ( modal.kind !== 'reveal' && ( - +