perf: optimize portfolio queries and workflows
This commit is contained in:
1 parent
da98d02661
commit
a7e0a0fe58
45 files changed
+4297
-488
No files matched your search
@@ -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。
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts",
|
||||
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "node scripts/database.cjs deploy",
|
||||
"db:status": "node scripts/database.cjs status",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts",
|
||||
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts",
|
||||
"test:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE INDEX `Revision_positionId_effectiveDate_sequence_idx` ON `Revision` (`positionId`, `effectiveDate`, `sequence`);
|
||||
CREATE INDEX `Revision_effectiveDate_sequence_idx` ON `Revision` (`effectiveDate`, `sequence`);
|
||||
CREATE INDEX `Revision_positionId_reason_effectiveDate_sequence_idx` ON `Revision` (`positionId`, `reason`, `effectiveDate`, `sequence`);
|
||||
DROP INDEX `Revision_positionId_effectiveDate_idx` ON `Revision`;
|
||||
CREATE INDEX `Transfer_userId_effectiveDate_id_idx` ON `Transfer` (`userId`, `effectiveDate`, `id`);
|
||||
DROP INDEX `Transfer_userId_effectiveDate_idx` ON `Transfer`;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Bounded per-position queries replace the global scan. EXPLAIN ANALYZE showed
|
||||
-- this competing index could scan other accounts before finding a match.
|
||||
DROP INDEX `Revision_effectiveDate_sequence_idx` ON `Revision`;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `Transfer` ADD COLUMN `operation` VARCHAR(16) NOT NULL DEFAULT 'transfer';
|
||||
@@ -0,0 +1,97 @@
|
||||
-- 仅增加中文注释;列定义来自现有 MySQL SHOW CREATE TABLE,保留类型、默认值、空值和自增属性。
|
||||
ALTER TABLE `User`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录用户名,唯一',
|
||||
MODIFY COLUMN `passwordHash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录密码的 bcrypt 哈希,不存储明文',
|
||||
MODIFY COLUMN `baseCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '本位币代码',
|
||||
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
|
||||
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
|
||||
MODIFY COLUMN `idleMinutes` int NOT NULL DEFAULT '30' COMMENT '无操作退出分钟数,0 表示关闭',
|
||||
MODIFY COLUMN `hiddenMenus` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '隐藏菜单标识列表',
|
||||
MODIFY COLUMN `showNotes` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否显示备注',
|
||||
COMMENT = '用户及个人设置';
|
||||
|
||||
ALTER TABLE `Session`
|
||||
MODIFY COLUMN `id` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '随机会话令牌的 SHA-256 摘要',
|
||||
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
|
||||
MODIFY COLUMN `expiresAt` datetime(3) NOT NULL COMMENT '会话过期时间,UTC',
|
||||
MODIFY COLUMN `lastActivity` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '最后有效交互时间,UTC',
|
||||
MODIFY COLUMN `revealUntil` datetime(3) DEFAULT NULL COMMENT '隐藏项目授权截止时间,UTC;空表示未解锁',
|
||||
MODIFY COLUMN `backupDigest` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已下载备份内容的 SHA-256 摘要',
|
||||
MODIFY COLUMN `backupExpiresAt` datetime(3) DEFAULT NULL COMMENT '备份清空确认凭据截止时间,UTC',
|
||||
COMMENT = '登录会话与隐藏项目查看授权';
|
||||
|
||||
ALTER TABLE `Position`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
|
||||
MODIFY COLUMN `kind` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目类型:account 账户、asset 独立资产、debt 债务',
|
||||
MODIFY COLUMN `side` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '统计方向:asset 资产或借出应收、liability 负债或借入应付',
|
||||
MODIFY COLUMN `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '显示名称',
|
||||
MODIFY COLUMN `category` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '项目分类代码',
|
||||
MODIFY COLUMN `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始币种代码',
|
||||
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
|
||||
MODIFY COLUMN `archived` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否归档;归档项目仍计入总额',
|
||||
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
|
||||
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
|
||||
MODIFY COLUMN `importedFromId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备份导入来源项目标识,用于重复导入识别',
|
||||
MODIFY COLUMN `hidden` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否隐藏;未解锁时不参与可见范围统计',
|
||||
MODIFY COLUMN `iconId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联账户图标标识',
|
||||
COMMENT = '持有项目:账户、独立资产和借入借出债务';
|
||||
|
||||
ALTER TABLE `Revision`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `positionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属持有项目标识',
|
||||
MODIFY COLUMN `amount` decimal(24,8) NOT NULL COMMENT '变更后的原币绝对余额,不是收支增量,禁止对全部历史求和',
|
||||
MODIFY COLUMN `effectiveDate` datetime(3) NOT NULL COMMENT '北京时间业务时间对应的 UTC,精确到分钟',
|
||||
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
|
||||
MODIFY COLUMN `reason` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更原因:初始、余额、估值、转账或借贷配对操作',
|
||||
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
|
||||
MODIFY COLUMN `updatedAt` datetime(3) NOT NULL COMMENT '记录更新时间,UTC',
|
||||
MODIFY COLUMN `sequence` int NOT NULL AUTO_INCREMENT COMMENT '数据库自增唯一顺序,同业务时间按此排序',
|
||||
COMMENT = '余额历史:金额为变更后的绝对余额';
|
||||
|
||||
ALTER TABLE `PositionLink`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `sourceId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联来源项目标识,通常为债务',
|
||||
MODIFY COLUMN `targetId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联目标项目标识,通常为账户或资产',
|
||||
COMMENT = '项目关联关系,不参与金额求和';
|
||||
|
||||
ALTER TABLE `ExchangeRate`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
|
||||
MODIFY COLUMN `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始币种代码',
|
||||
MODIFY COLUMN `baseCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本位币代码',
|
||||
MODIFY COLUMN `date` date NOT NULL COMMENT '汇率业务日期',
|
||||
MODIFY COLUMN `rate` decimal(24,12) NOT NULL COMMENT '每单位原币对应的本位币金额',
|
||||
MODIFY COLUMN `source` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率来源标识,保留已有历史来源',
|
||||
COMMENT = '用户原币兑本位币的历史汇率';
|
||||
|
||||
ALTER TABLE `Icon`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `ownerId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传用户标识;空可表示系统图标或原用户已删除',
|
||||
MODIFY COLUMN `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '显示名称',
|
||||
MODIFY COLUMN `shared` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否公开共享给已登录用户',
|
||||
MODIFY COLUMN `hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化图标内容的 SHA-256 摘要',
|
||||
MODIFY COLUMN `data` mediumblob NOT NULL COMMENT '规范化静态 PNG 图片二进制',
|
||||
MODIFY COLUMN `source` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选公开来源网址',
|
||||
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
|
||||
COMMENT = '可复用账户图标';
|
||||
|
||||
ALTER TABLE `Transfer`
|
||||
MODIFY COLUMN `id` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '唯一标识',
|
||||
MODIFY COLUMN `importedFromId` char(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备份导入来源项目标识,用于重复导入识别',
|
||||
MODIFY COLUMN `userId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属用户标识,用于数据隔离',
|
||||
MODIFY COLUMN `sourceId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源资产账户标识',
|
||||
MODIFY COLUMN `targetId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标账户或债务标识',
|
||||
MODIFY COLUMN `sourceRevisionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源账户配对余额历史标识',
|
||||
MODIFY COLUMN `targetRevisionId` char(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标项目配对余额历史标识',
|
||||
MODIFY COLUMN `sourceCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源账户币种',
|
||||
MODIFY COLUMN `targetCurrency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标项目币种',
|
||||
MODIFY COLUMN `amount` decimal(24,8) NOT NULL COMMENT '本次来源账户本金金额,不是变更后余额',
|
||||
MODIFY COLUMN `received` decimal(24,8) NOT NULL COMMENT '本次目标到账金额或债务本金金额',
|
||||
MODIFY COLUMN `fee` decimal(24,8) NOT NULL COMMENT '来源币种手续费;负数表示优惠',
|
||||
MODIFY COLUMN `effectiveDate` datetime(3) NOT NULL COMMENT '北京时间业务时间对应的 UTC,精确到分钟',
|
||||
MODIFY COLUMN `notes` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户备注,原文保留',
|
||||
MODIFY COLUMN `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间,UTC',
|
||||
MODIFY COLUMN `operation` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'transfer' COMMENT '操作类型:transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款',
|
||||
COMMENT = '资金往来及账户与债务的双边配对记账';
|
||||
@@ -5,15 +5,25 @@ datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
/// 用户及个人设置
|
||||
model User {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 登录用户名,唯一
|
||||
username String @unique @db.VarChar(64)
|
||||
/// 登录密码的 bcrypt 哈希,不存储明文
|
||||
passwordHash String @db.VarChar(255)
|
||||
/// 本位币代码
|
||||
baseCurrency String @default("CNY") @db.Char(3)
|
||||
/// 隐藏菜单标识列表
|
||||
hiddenMenus String @default("") @db.VarChar(128)
|
||||
/// 是否显示备注
|
||||
showNotes Boolean @default(true)
|
||||
/// 无操作退出分钟数,0 表示关闭
|
||||
idleMinutes Int @default(30)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
/// 记录更新时间,UTC
|
||||
updatedAt DateTime @updatedAt
|
||||
positions Position[]
|
||||
rates ExchangeRate[]
|
||||
@@ -21,32 +31,55 @@ model User {
|
||||
icons Icon[]
|
||||
transfers Transfer[]
|
||||
}
|
||||
/// 登录会话与隐藏项目查看授权
|
||||
model Session {
|
||||
/// 随机会话令牌的 SHA-256 摘要
|
||||
id String @id @db.Char(64)
|
||||
/// 所属用户标识,用于数据隔离
|
||||
userId String @db.Char(36)
|
||||
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||
/// 会话过期时间,UTC
|
||||
expiresAt DateTime
|
||||
/// 最后有效交互时间,UTC
|
||||
lastActivity DateTime @default(now())
|
||||
/// 隐藏项目授权截止时间,UTC;空表示未解锁
|
||||
revealUntil DateTime?
|
||||
/// 已下载备份内容的 SHA-256 摘要
|
||||
backupDigest String? @db.Char(64)
|
||||
/// 备份清空确认凭据截止时间,UTC
|
||||
backupExpiresAt DateTime?
|
||||
@@index([userId])
|
||||
}
|
||||
/// 持有项目:账户、独立资产和借入借出债务
|
||||
model Position {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 备份导入来源项目标识,用于重复导入识别
|
||||
importedFromId String? @db.Char(36)
|
||||
/// 所属用户标识,用于数据隔离
|
||||
userId String @db.Char(36)
|
||||
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||
/// 项目类型:account 账户、asset 独立资产、debt 债务
|
||||
kind String @db.VarChar(16)
|
||||
/// 统计方向:asset 资产或借出应收、liability 负债或借入应付
|
||||
side String @db.VarChar(16)
|
||||
/// 显示名称
|
||||
name String @db.VarChar(100)
|
||||
/// 项目分类代码
|
||||
category String @db.VarChar(40)
|
||||
/// 原始币种代码
|
||||
currency String @db.Char(3)
|
||||
/// 用户备注,原文保留
|
||||
notes String @db.Text
|
||||
/// 是否归档;归档项目仍计入总额
|
||||
archived Boolean @default(false)
|
||||
/// 是否隐藏;未解锁时不参与可见范围统计
|
||||
hidden Boolean @default(false)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
/// 记录更新时间,UTC
|
||||
updatedAt DateTime @updatedAt
|
||||
/// 关联账户图标标识
|
||||
iconId String? @db.Char(36)
|
||||
icon Icon? @relation(fields:[iconId],references:[id],onDelete:SetNull)
|
||||
revisions Revision[]
|
||||
@@ -57,73 +90,123 @@ model Position {
|
||||
@@index([userId,kind])
|
||||
@@unique([userId,importedFromId])
|
||||
}
|
||||
/// 余额历史:金额为变更后的绝对余额
|
||||
model Revision {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 数据库自增唯一顺序,同业务时间按此排序
|
||||
sequence Int @unique @default(autoincrement())
|
||||
/// 所属持有项目标识
|
||||
positionId String @db.Char(36)
|
||||
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
|
||||
/// 变更后的原币绝对余额,不是收支增量,禁止对全部历史求和
|
||||
amount Decimal @db.Decimal(24,8)
|
||||
/// 北京时间业务时间对应的 UTC,精确到分钟
|
||||
effectiveDate DateTime @db.DateTime(3)
|
||||
/// 用户备注,原文保留
|
||||
notes String @db.Text
|
||||
/// 变更原因:初始、余额、估值、转账或借贷配对操作
|
||||
reason String @db.VarChar(20)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
/// 记录更新时间,UTC
|
||||
updatedAt DateTime @updatedAt
|
||||
@@index([positionId,effectiveDate])
|
||||
@@index([positionId,effectiveDate,sequence])
|
||||
@@index([positionId,reason,effectiveDate,sequence])
|
||||
}
|
||||
/// 项目关联关系,不参与金额求和
|
||||
model PositionLink {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 关联来源项目标识,通常为债务
|
||||
sourceId String @db.Char(36)
|
||||
/// 关联目标项目标识,通常为账户或资产
|
||||
targetId String @db.Char(36)
|
||||
source Position @relation("Source",fields:[sourceId],references:[id],onDelete:Cascade)
|
||||
target Position @relation("Target",fields:[targetId],references:[id],onDelete:Cascade)
|
||||
@@unique([sourceId,targetId])
|
||||
}
|
||||
/// 用户原币兑本位币的历史汇率
|
||||
model ExchangeRate {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 所属用户标识,用于数据隔离
|
||||
userId String @db.Char(36)
|
||||
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||
/// 原始币种代码
|
||||
currency String @db.Char(3)
|
||||
/// 本位币代码
|
||||
baseCurrency String @db.Char(3)
|
||||
/// 汇率业务日期
|
||||
date DateTime @db.Date
|
||||
/// 每单位原币对应的本位币金额
|
||||
rate Decimal @db.Decimal(24,12)
|
||||
/// 汇率来源标识,保留已有历史来源
|
||||
source String @db.VarChar(30)
|
||||
@@unique([userId,currency,baseCurrency,date])
|
||||
}
|
||||
|
||||
/// 可复用账户图标
|
||||
model Icon {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 上传用户标识;空可表示系统图标或原用户已删除
|
||||
ownerId String? @db.Char(36)
|
||||
owner User? @relation(fields:[ownerId],references:[id],onDelete:SetNull)
|
||||
/// 显示名称
|
||||
name String @db.VarChar(100)
|
||||
/// 是否公开共享给已登录用户
|
||||
shared Boolean @default(false)
|
||||
/// 规范化图标内容的 SHA-256 摘要
|
||||
hash String @db.Char(64)
|
||||
/// 规范化静态 PNG 图片二进制
|
||||
data Bytes @db.MediumBlob
|
||||
/// 可选公开来源网址
|
||||
source String? @db.VarChar(500)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
positions Position[]
|
||||
@@unique([ownerId,hash,shared])
|
||||
@@index([shared,name])
|
||||
}
|
||||
|
||||
/// 资金往来及账户与债务的双边配对记账
|
||||
model Transfer {
|
||||
/// 唯一标识
|
||||
id String @id @default(uuid()) @db.Char(36)
|
||||
/// 备份导入来源项目标识,用于重复导入识别
|
||||
importedFromId String? @db.Char(36)
|
||||
/// 所属用户标识,用于数据隔离
|
||||
userId String @db.Char(36)
|
||||
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
|
||||
/// 来源资产账户标识
|
||||
sourceId String @db.Char(36)
|
||||
/// 目标账户或债务标识
|
||||
targetId String @db.Char(36)
|
||||
source Position @relation("TransferSource",fields:[sourceId],references:[id],onDelete:Cascade)
|
||||
target Position @relation("TransferTarget",fields:[targetId],references:[id],onDelete:Cascade)
|
||||
/// 来源账户配对余额历史标识
|
||||
sourceRevisionId String @unique @db.Char(36)
|
||||
/// 目标项目配对余额历史标识
|
||||
targetRevisionId String @unique @db.Char(36)
|
||||
/// 来源账户币种
|
||||
sourceCurrency String @db.Char(3)
|
||||
/// 目标项目币种
|
||||
targetCurrency String @db.Char(3)
|
||||
/// 本次来源账户本金金额,不是变更后余额
|
||||
amount Decimal @db.Decimal(24,8)
|
||||
/// 本次目标到账金额或债务本金金额
|
||||
received Decimal @db.Decimal(24,8)
|
||||
/// 来源币种手续费;负数表示优惠
|
||||
fee Decimal @db.Decimal(24,8)
|
||||
/// 北京时间业务时间对应的 UTC,精确到分钟
|
||||
effectiveDate DateTime @db.DateTime(3)
|
||||
/// 用户备注,原文保留
|
||||
notes String @db.Text
|
||||
/// 操作类型:transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款
|
||||
operation String @default("transfer") @db.VarChar(16)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
@@unique([userId,importedFromId])
|
||||
@@index([userId,effectiveDate])
|
||||
@@index([userId,effectiveDate,id])
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { history, overview, totals, trend } from '../src/calculation';
|
||||
import { currentPositions, historyPage, trendData, currentRates } from '../src/queries';
|
||||
import { today, toBusinessDate } from '../src/validation';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
// Dedicated random users only; no existing user's rows are read or changed.
|
||||
const phase = process.argv[2] || 'after';
|
||||
const db = new PrismaClient({
|
||||
transactionOptions: { timeout: 120000, maxWait: 10000 },
|
||||
log: [{ emit: 'event', level: 'query' }],
|
||||
});
|
||||
let events: { query: string; params: string; duration: number }[] = [];
|
||||
db.$on('query', (e) => events.push(e));
|
||||
const results: unknown[] = [];
|
||||
async function measure(name: string, work: () => Promise<unknown>) {
|
||||
console.log('Measuring ' + name);
|
||||
const times: number[] = [];
|
||||
let bytes = 0,
|
||||
queries = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
events = [];
|
||||
const start = performance.now();
|
||||
const value = await work();
|
||||
times.push(performance.now() - start);
|
||||
bytes = Buffer.byteLength(JSON.stringify(value));
|
||||
queries = events.length;
|
||||
}
|
||||
return { name, medianMs: Number(times.sort((a, b) => a - b)[1].toFixed(2)), bytes, queries };
|
||||
}
|
||||
async function plan(event: (typeof events)[number]) {
|
||||
const params = JSON.parse(event.params).map((p: unknown) =>
|
||||
typeof p === 'string' && /^\d{4}-\d\d-\d\d[ T]\d\d:/.test(p) ? new Date(p) : p,
|
||||
);
|
||||
try {
|
||||
return await db.$queryRawUnsafe('EXPLAIN ANALYZE ' + event.query, ...params);
|
||||
} catch {
|
||||
return await db.$queryRawUnsafe('EXPLAIN ' + event.query, ...params);
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
for (const [accounts, count] of [
|
||||
[1, 10000],
|
||||
[1, 100000],
|
||||
[10, 10000],
|
||||
]) {
|
||||
const user = await db.user.create({
|
||||
data: { username: 'wp_perf_' + randomUUID(), passwordHash: 'not-a-login-hash' },
|
||||
});
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
const end = today(),
|
||||
start = new Date(+new Date(end) - 89 * 86400000).toISOString().slice(0, 10);
|
||||
for (let a = 0; a < accounts; a++) {
|
||||
const p = await db.position.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
name: 'benchmark',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
ids.push(p.id);
|
||||
for (let offset = 0; offset < count; offset += 1000) {
|
||||
await db.revision.createMany({
|
||||
data: Array.from({ length: Math.min(1000, count - offset) }, (_, n) => ({
|
||||
positionId: p.id,
|
||||
amount: String(offset + n + 1),
|
||||
notes: '',
|
||||
reason: 'balance',
|
||||
effectiveDate: new Date(
|
||||
+toBusinessDate(start) + Math.floor(((offset + n) * 89) / (count - 1)) * 86400000,
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(token).digest('hex'),
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
const httpBase = process.env.TEST_API_URL || 'http://127.0.0.1:33101/api';
|
||||
const http = async (path: string, base = httpBase) => {
|
||||
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
|
||||
if (!response.ok) throw Error('HTTP benchmark status ' + response.status);
|
||||
return response.json();
|
||||
};
|
||||
const oldList = () =>
|
||||
db.$transaction(async (tx) => {
|
||||
const rows = await tx.position.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
},
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
...p,
|
||||
userId: undefined,
|
||||
amount: p.revisions.at(-1)?.amount.toString() || '0',
|
||||
history: history(p),
|
||||
}));
|
||||
});
|
||||
const oldOverview = () =>
|
||||
db.$transaction(
|
||||
async (tx) => {
|
||||
const rows = await tx.position.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { revisions: true },
|
||||
});
|
||||
return overview(rows, [], 'CNY', end);
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
const baseline = [
|
||||
await measure('old positions', oldList),
|
||||
await measure('old overview', oldOverview),
|
||||
];
|
||||
const optimized = [
|
||||
await measure('new positions', () =>
|
||||
db.$transaction(async (tx) => {
|
||||
const rows = await currentPositions(tx, user.id, false);
|
||||
const rates = await currentRates(tx, user.id, ['CNY'], 'CNY', end);
|
||||
const amounts = new Map(totals(rows, rates, 'CNY', end).items.map((p) => [p.id, p]));
|
||||
return rows.map(({ revisions, userId, ...p }) => ({
|
||||
...p,
|
||||
amount: revisions[0]?.amount.toString() || '0',
|
||||
converted: amounts.get(p.id)!.converted,
|
||||
rateDate: amounts.get(p.id)!.rateDate,
|
||||
}));
|
||||
}),
|
||||
),
|
||||
await measure('new current overview', () =>
|
||||
db.$transaction(async (tx) =>
|
||||
totals(await currentPositions(tx, user.id, false), [], 'CNY', end),
|
||||
),
|
||||
),
|
||||
await measure('new history page', () =>
|
||||
db.$transaction((tx) => historyPage(tx, user.id, false, { limit: 50 })),
|
||||
),
|
||||
await measure('new trend', () =>
|
||||
db.$transaction(
|
||||
async (tx) => {
|
||||
const data = await trendData(tx, user.id, false, start, end);
|
||||
return trend(data.positions, data.rates, 'CNY', start, end);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
),
|
||||
),
|
||||
];
|
||||
events = [];
|
||||
await db.position.findMany({
|
||||
where: { userId: user.id },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||
},
|
||||
});
|
||||
const nestedSql = events.filter((e) => e.query.startsWith('SELECT')).map((e) => e.query);
|
||||
events = [];
|
||||
await currentPositions(db, user.id, false);
|
||||
const latestEvent = events.find((e) => e.query.includes('SELECT r.*'))!;
|
||||
const latestPlan = await plan(latestEvent);
|
||||
events = [];
|
||||
await historyPage(db, user.id, false, { limit: 50 });
|
||||
const pageEvent = events.find((e) => e.query.includes('AS beforeAmount'))!;
|
||||
const candidateEvents = events.filter(
|
||||
(e) =>
|
||||
e.query.includes('Revision') &&
|
||||
!e.query.includes('beforeAmount') &&
|
||||
e.query.startsWith('SELECT'),
|
||||
);
|
||||
const pagePlan = await plan(pageEvent);
|
||||
const candidatePlans = await Promise.all(candidateEvents.map(plan));
|
||||
const first = await historyPage(db, user.id, false, { limit: 50 });
|
||||
const second = await historyPage(db, user.id, false, { limit: 50, cursor: first.nextCursor });
|
||||
const deep = await historyPage(db, user.id, false, { limit: 50, to: start });
|
||||
if (
|
||||
first.items.some((a) => second.items.some((b) => a.id === b.id)) ||
|
||||
deep.items.some((r) => r.delta !== '1')
|
||||
)
|
||||
throw Error('Pagination boundary mismatch');
|
||||
const httpResults = [
|
||||
await measure('HTTP positions', () => http('/positions?kind=account')),
|
||||
await measure('HTTP overview', () => http('/overview')),
|
||||
await measure('HTTP history', () => http('/history?limit=50')),
|
||||
await measure('HTTP trend', () => http('/trend?from=' + start + '&to=' + end)),
|
||||
];
|
||||
const oldHttpBase = process.env.BASELINE_API_URL;
|
||||
const baselineHttp = oldHttpBase
|
||||
? [
|
||||
await measure('old HTTP positions', () => http('/positions', oldHttpBase)),
|
||||
await measure('old HTTP overview', () => http('/overview', oldHttpBase)),
|
||||
]
|
||||
: [];
|
||||
results.push({
|
||||
accounts,
|
||||
revisionsPerAccount: count,
|
||||
baseline,
|
||||
optimized,
|
||||
httpResults,
|
||||
baselineHttp,
|
||||
nestedSql,
|
||||
latestPlan,
|
||||
pagePlan,
|
||||
candidatePlans,
|
||||
});
|
||||
writeFileSync(
|
||||
`../../docs/performance-${phase}.json`,
|
||||
JSON.stringify(
|
||||
{ phase, node: process.version, results },
|
||||
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ phase, accounts, count, baseline, optimized, httpResults, baselineHttp }),
|
||||
);
|
||||
} finally {
|
||||
await db.user.delete({ where: { id: user.id } });
|
||||
}
|
||||
}
|
||||
writeFileSync(
|
||||
`../../docs/performance-${phase}.json`,
|
||||
JSON.stringify(
|
||||
{ phase, node: process.version, results },
|
||||
(_k, v) => (typeof v === 'bigint' ? v.toString() : v),
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
run()
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
'Benchmark failed: ' +
|
||||
(e.code || e.constructor.name) +
|
||||
' ' +
|
||||
String(e.message)
|
||||
.replace(/mysql:\/\/[^\s]+/g, '[redacted]')
|
||||
.slice(-600),
|
||||
);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => db.$disconnect());
|
||||
+11
-5
@@ -1,3 +1,5 @@
|
||||
import { movementDeltas } from './movement';
|
||||
import { pairedReasons } from './validation';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -159,9 +161,11 @@ export function validateBackup(raw: unknown) {
|
||||
!source ||
|
||||
!target ||
|
||||
source.kind !== 'account' ||
|
||||
target.kind !== 'account' ||
|
||||
source.side !== 'asset' ||
|
||||
target.side !== 'asset' ||
|
||||
(t.operation === 'transfer'
|
||||
? target.kind !== 'account' || target.side !== 'asset'
|
||||
: target.kind !== 'debt' ||
|
||||
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
|
||||
source.currency !== t.sourceCurrency ||
|
||||
target.currency !== t.targetCurrency
|
||||
)
|
||||
@@ -169,9 +173,10 @@ export function validateBackup(raw: unknown) {
|
||||
transferIds.add(origin);
|
||||
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
||||
throw new BadRequestException('同币种转账金额不一致');
|
||||
const deltas = movementDeltas(t.operation, t.amount, t.received, t.fee);
|
||||
for (const [p, revId, reason, delta] of [
|
||||
[source, t.sourceRevisionId, 'transfer_out', new Decimal(t.amount).plus(t.fee).neg()],
|
||||
[target, t.targetRevisionId, 'transfer_in', new Decimal(t.received)],
|
||||
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
|
||||
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
|
||||
] as const) {
|
||||
const ordered = [...p.revisions].sort(
|
||||
(a, b) =>
|
||||
@@ -195,7 +200,7 @@ export function validateBackup(raw: unknown) {
|
||||
}
|
||||
if (
|
||||
b.positions.some((p) =>
|
||||
p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)),
|
||||
p.revisions.some((r) => pairedReasons.includes(r.reason) && !usedRevisions.has(r.id)),
|
||||
)
|
||||
)
|
||||
throw new BadRequestException('缺少配对转账记录');
|
||||
@@ -614,6 +619,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
amount: t.amount,
|
||||
received: t.received,
|
||||
fee: t.fee,
|
||||
operation: t.operation,
|
||||
effectiveDate: toBusinessDate(t.date),
|
||||
notes: t.notes,
|
||||
createdAt: new Date(t.createdAt),
|
||||
|
||||
@@ -142,3 +142,79 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
|
||||
.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
// Sort once, advance each revision/rate once, and retain only the running balances.
|
||||
// Sampling uses end-of-period balances; attribution sums daily changes in the bucket.
|
||||
export function trend(
|
||||
positions: Holding[],
|
||||
rates: Rate[],
|
||||
base: string,
|
||||
from: string,
|
||||
to: string,
|
||||
grain: 'day' | 'week' | 'month' = 'day',
|
||||
) {
|
||||
const streams = positions.map((p) => ({
|
||||
p,
|
||||
rows: [...p.revisions].sort(compareRevisions),
|
||||
index: 0,
|
||||
current: [] as Holding['revisions'],
|
||||
}));
|
||||
const fxRows = rates.filter((r) => r.baseCurrency === base).sort((a, b) => +a.date - +b.date);
|
||||
let fxIndex = 0;
|
||||
const fx = new Map<string, Rate>();
|
||||
function advance(d: string) {
|
||||
for (const s of streams)
|
||||
while (s.index < s.rows.length && businessDay(s.rows[s.index].effectiveDate) <= d)
|
||||
s.current = [s.rows[s.index++]];
|
||||
while (fxIndex < fxRows.length && day(fxRows[fxIndex].date) <= d) {
|
||||
const r = fxRows[fxIndex++];
|
||||
fx.set(r.currency, r);
|
||||
}
|
||||
return totals(
|
||||
streams.map((s) => ({ ...s.p, revisions: s.current })),
|
||||
[...fx.values()],
|
||||
base,
|
||||
d,
|
||||
);
|
||||
}
|
||||
let previous = advance(new Date(+new Date(from) - 86400000).toISOString().slice(0, 10));
|
||||
const output: (Omit<ReturnType<typeof totals>, 'items'> & {
|
||||
balanceChange: string | null;
|
||||
fxChange: string | null;
|
||||
})[] = [];
|
||||
let balance = new Decimal(0),
|
||||
exchange = new Decimal(0),
|
||||
complete = true;
|
||||
for (let time = +new Date(from); time <= +new Date(to); time += 86400000) {
|
||||
const d = new Date(time).toISOString().slice(0, 10),
|
||||
value = advance(d);
|
||||
if (previous.complete && value.complete) {
|
||||
let revalued = new Decimal(0);
|
||||
for (const item of previous.items) {
|
||||
const rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate;
|
||||
const v = new Decimal(item.amount).mul(rate?.toString() || '0');
|
||||
revalued = revalued.plus(item.side === 'asset' ? v : v.neg());
|
||||
}
|
||||
exchange = exchange.plus(revalued.minus(previous.net));
|
||||
balance = balance.plus(new Decimal(value.net).minus(revalued));
|
||||
} else complete = false;
|
||||
previous = value;
|
||||
const next = new Date(time + 86400000);
|
||||
if (
|
||||
grain === 'day' ||
|
||||
d === to ||
|
||||
(grain === 'week' ? next.getUTCDay() === 1 : next.getUTCDate() === 1)
|
||||
) {
|
||||
const { items, ...total } = value;
|
||||
output.push({
|
||||
...total,
|
||||
balanceChange: complete ? balance.toFixed(2) : null,
|
||||
fxChange: complete ? exchange.toFixed(2) : null,
|
||||
});
|
||||
balance = new Decimal(0);
|
||||
exchange = new Decimal(0);
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
@Injectable()
|
||||
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await this.$transaction(work, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
});
|
||||
} catch (error) {
|
||||
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
||||
const retryable =
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(error.code === 'P2034' ||
|
||||
(error.code === 'P2010' && ['1213', '1205'].includes(String(error.meta?.code))));
|
||||
if (!retryable || attempt >= 3) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt + Math.random() * 20));
|
||||
}
|
||||
}
|
||||
}
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Decimal from 'decimal.js';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
export type Movement = 'transfer' | 'borrow' | 'lend' | 'collect' | 'repay';
|
||||
export function movementDeltas(operation: Movement, amount: string, received: string, fee: string) {
|
||||
const incoming = operation === 'borrow' || operation === 'collect';
|
||||
const principal = new Decimal(amount),
|
||||
charge = new Decimal(fee),
|
||||
debt = new Decimal(received);
|
||||
if (operation === 'transfer')
|
||||
return {
|
||||
source: principal.plus(charge).neg(),
|
||||
target: debt,
|
||||
sourceReason: 'transfer_out',
|
||||
targetReason: 'transfer_in',
|
||||
};
|
||||
if (incoming && charge.gt(principal)) throw new BadRequestException('收款手续费不能超过收款金额');
|
||||
return {
|
||||
source: incoming ? principal.minus(charge) : principal.plus(charge).neg(),
|
||||
target: operation === 'borrow' || operation === 'lend' ? debt : debt.neg(),
|
||||
sourceReason: 'loan_' + operation,
|
||||
targetReason: 'loan_' + operation,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,49 @@ export function setupOpenApi(app: INestApplication) {
|
||||
const operation = entry[method];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
if (
|
||||
method === 'get' &&
|
||||
['/api/history', '/api/positions/{id}/history', '/api/transfers'].includes(path)
|
||||
) {
|
||||
operation.parameters = [
|
||||
...(operation.parameters || []),
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
schema: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
in: 'query',
|
||||
schema: { type: 'string' },
|
||||
description: '上页 nextCursor;业务时间及唯一顺序键降序,写入或更正后重新读取第一页',
|
||||
},
|
||||
...['from', 'to'].map((name) => ({
|
||||
name,
|
||||
in: 'query' as const,
|
||||
schema: { type: 'string', format: 'date' },
|
||||
})),
|
||||
];
|
||||
operation.responses['200'] = {
|
||||
description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额',
|
||||
};
|
||||
}
|
||||
if (method === 'get' && path === '/api/trend') {
|
||||
operation.parameters = [
|
||||
...['from', 'to'].map((name) => ({
|
||||
name,
|
||||
in: 'query' as const,
|
||||
schema: { type: 'string', format: 'date' },
|
||||
})),
|
||||
{
|
||||
name: 'grain',
|
||||
in: 'query',
|
||||
schema: { type: 'string', enum: ['day', 'week', 'month'], default: 'day' },
|
||||
},
|
||||
];
|
||||
operation.description =
|
||||
'默认90天;日最多366天,周最多1096天,月最多3653天。取北京时间期末余额,归因累计每日变化;不返回每天项目明细。';
|
||||
}
|
||||
if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path))
|
||||
operation.security = [];
|
||||
const schema = bodies[method.toUpperCase() + ' ' + path];
|
||||
|
||||
+113
-60
@@ -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 },
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
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,
|
||||
userId: undefined,
|
||||
amount: p.revisions.at(-1)?.amount.toString() || '0',
|
||||
history: history(p),
|
||||
amount: revisions[0]?.amount.toString() || '0',
|
||||
converted: amounts.get(p.id)!.converted,
|
||||
rateDate: amounts.get(p.id)!.rateDate,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
const p = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, userId: undefined, history: history(p) };
|
||||
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
|
||||
}
|
||||
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
|
||||
}
|
||||
@Get('positions/:id/history') async positionHistory(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Query() query: Record<string, string>,
|
||||
) {
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
|
||||
});
|
||||
}
|
||||
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
const q = trendInput(query);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const data = await trendData(tx, r.userId, r.revealed, q.from, q.to);
|
||||
return {
|
||||
...q,
|
||||
baseCurrency: data.base,
|
||||
revealed: r.revealed,
|
||||
items: trend(data.positions, data.rates, data.base, q.from, q.to, q.grain),
|
||||
};
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const v = positionInput.parse(b),
|
||||
@@ -102,16 +146,18 @@ 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) => {
|
||||
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: ['transfer_out', 'transfer_in'] } },
|
||||
where: { positionId: p.id, reason: { in: pairedReasons } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate)
|
||||
@@ -134,9 +180,7 @@ export class PortfolioController {
|
||||
reason: v.reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
});
|
||||
}
|
||||
@Put('positions/:id/revisions/:revisionId') async correct(
|
||||
@Req() r: UserRequest,
|
||||
@@ -145,24 +189,31 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
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 }) },
|
||||
include: { revisions: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const original = p.revisions.find((h) => h.id === revisionId);
|
||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
if (
|
||||
p.revisions.some(
|
||||
(h) =>
|
||||
h.reason.startsWith('transfer_') &&
|
||||
(+h.effectiveDate >= +original.effectiveDate ||
|
||||
toBusinessDate(v.date) < h.effectiveDate),
|
||||
)
|
||||
)
|
||||
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(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
@@ -176,9 +227,7 @@ export class PortfolioController {
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Put('positions/:id/links') async link(
|
||||
@@ -192,8 +241,7 @@ export class PortfolioController {
|
||||
.parse(b);
|
||||
if (new Set(targetIds).size !== targetIds.length || targetIds.includes(id))
|
||||
throw new BadRequestException('关联不能重复或指向自身');
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
return this.db.serial(async (tx) => {
|
||||
const source = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
@@ -207,23 +255,28 @@ export class PortfolioController {
|
||||
data: targetIds.map((targetId) => ({ sourceId: id, targetId })),
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
});
|
||||
}
|
||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||
void this.fx.daily(r.userId);
|
||||
const [user, positions, rates] = await this.db.$transaction([
|
||||
this.db.user.findUniqueOrThrow({
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { baseCurrency: true },
|
||||
}),
|
||||
this.db.position.findMany({
|
||||
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: { revisions: true },
|
||||
}),
|
||||
this.db.exchangeRate.findMany({ where: { userId: r.userId } }),
|
||||
]);
|
||||
return { ...overview(positions, rates, user.baseCurrency, today()), revealed: r.revealed };
|
||||
});
|
||||
const positions = await currentPositions(tx, r.userId, r.revealed);
|
||||
const rates = await currentRates(
|
||||
tx,
|
||||
r.userId,
|
||||
positions.map((p) => p.currency),
|
||||
user.baseCurrency,
|
||||
today(),
|
||||
);
|
||||
return {
|
||||
baseCurrency: user.baseCurrency,
|
||||
...totals(positions, rates, user.baseCurrency, today()),
|
||||
revealed: r.revealed,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { date, today, toBusinessDate } from './validation';
|
||||
import { Holding, Rate, businessDay, businessTime, history } from './calculation';
|
||||
import Decimal from 'decimal.js';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
type Reader = Prisma.TransactionClient;
|
||||
export const revisionOrder = [{ effectiveDate: 'desc' }, { sequence: 'desc' }] as const;
|
||||
const cursorSchema = z
|
||||
.object({ time: z.string().datetime(), key: z.string().min(1).max(64) })
|
||||
.strict();
|
||||
export function pageInput(query: unknown) {
|
||||
const q = z
|
||||
.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
cursor: z.string().max(512).optional(),
|
||||
positionId: z.string().uuid().optional(),
|
||||
from: date.optional(),
|
||||
to: date.optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(query);
|
||||
const cursor = q.cursor
|
||||
? cursorSchema.parse(JSON.parse(Buffer.from(q.cursor, 'base64url').toString()))
|
||||
: null;
|
||||
if (q.from && q.to && q.from > q.to) throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
return { ...q, cursor };
|
||||
}
|
||||
export const encodeCursor = (time: Date, key: string | number) =>
|
||||
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
|
||||
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
|
||||
|
||||
export async function transferPageIds(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
q: ReturnType<typeof pageInput>,
|
||||
) {
|
||||
// Keep the ordered transfer index as the driving table. A visibility JOIN can
|
||||
// otherwise make MySQL scan source accounts first and sort all their transfers.
|
||||
return db.$queryRaw<{ id: string }[]>(Prisma.sql`
|
||||
SELECT t.id FROM Transfer t FORCE INDEX (Transfer_userId_effectiveDate_id_idx)
|
||||
STRAIGHT_JOIN Position s ON s.id = t.sourceId
|
||||
STRAIGHT_JOIN Position d ON d.id = t.targetId
|
||||
WHERE t.userId = ${userId} AND s.userId = ${userId} AND d.userId = ${userId}
|
||||
${revealed ? Prisma.empty : Prisma.sql`AND s.hidden = false AND d.hidden = false`}
|
||||
${q.positionId ? Prisma.sql`AND (t.sourceId = ${q.positionId} OR t.targetId = ${q.positionId})` : Prisma.empty}
|
||||
${q.from ? Prisma.sql`AND t.effectiveDate >= ${toBusinessDate(q.from)}` : Prisma.empty}
|
||||
${q.to ? Prisma.sql`AND t.effectiveDate < ${endOfDay(q.to)}` : Prisma.empty}
|
||||
${
|
||||
q.cursor
|
||||
? Prisma.sql`AND (t.effectiveDate < ${new Date(q.cursor.time)} OR
|
||||
(t.effectiveDate = ${new Date(q.cursor.time)} AND t.id < ${q.cursor.key}))`
|
||||
: Prisma.empty
|
||||
}
|
||||
ORDER BY t.effectiveDate DESC, t.id DESC LIMIT ${q.limit + 1}`);
|
||||
}
|
||||
|
||||
// Each branch has constant bounds so MySQL can use an index range even for a
|
||||
// historical seed. Prisma 6's nested take can fetch unbounded rows for multiple parents.
|
||||
export async function latestRevisions(db: Reader, ids: string[], before: Date) {
|
||||
if (!ids.length) return [];
|
||||
const result: (Holding['revisions'][number] & { positionId: string })[] = [];
|
||||
// Bound UNION size for users with many positions.
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const branches = ids.slice(offset, offset + 100).map(
|
||||
(id) => Prisma.sql`
|
||||
(SELECT r.* FROM Revision r FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
|
||||
WHERE r.positionId = ${id} AND r.effectiveDate < ${before}
|
||||
ORDER BY r.effectiveDate DESC, r.sequence DESC LIMIT 1)`,
|
||||
);
|
||||
result.push(...(await db.$queryRaw<typeof result>(Prisma.join(branches, ' UNION ALL '))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function currentPositions(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
id?: string,
|
||||
kind?: string,
|
||||
) {
|
||||
const positions = await db.position.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(revealed ? {} : { hidden: false }),
|
||||
...(id ? { id } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
},
|
||||
include: {
|
||||
outgoing: {
|
||||
where: { target: { userId, ...(revealed ? {} : { hidden: false }) } },
|
||||
include: { target: { select: { name: true, kind: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const latest = await latestRevisions(
|
||||
db,
|
||||
positions.map((p) => p.id),
|
||||
endOfDay(today()),
|
||||
);
|
||||
const byId = new Map(latest.map((r) => [r.positionId, r]));
|
||||
return positions.map((p) => ({ ...p, revisions: byId.has(p.id) ? [byId.get(p.id)!] : [] }));
|
||||
}
|
||||
export async function currentRates(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
currencies: string[],
|
||||
base: string,
|
||||
before: string,
|
||||
) {
|
||||
const rows = await Promise.all(
|
||||
[...new Set(currencies)]
|
||||
.filter((c) => c !== base)
|
||||
.map((currency) =>
|
||||
db.exchangeRate.findFirst({
|
||||
where: { userId, currency, baseCurrency: base, date: { lte: new Date(before) } },
|
||||
orderBy: { date: 'desc' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return rows.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
}
|
||||
export async function historyPage(db: Reader, userId: string, revealed: boolean, query: unknown) {
|
||||
const q = pageInput(query);
|
||||
const positions = await db.position.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(revealed ? {} : { hidden: false }),
|
||||
...(q.positionId ? { id: q.positionId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, kind: true, currency: true },
|
||||
});
|
||||
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
|
||||
// sort an entire user's history (and evaluate the predecessor for every row).
|
||||
const candidates = (
|
||||
await Promise.all(
|
||||
positions.map((p) =>
|
||||
db.revision
|
||||
.findMany({
|
||||
where: {
|
||||
positionId: p.id,
|
||||
...(q.from || q.to
|
||||
? {
|
||||
effectiveDate: {
|
||||
...(q.from ? { gte: toBusinessDate(q.from) } : {}),
|
||||
...(q.to ? { lt: endOfDay(q.to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(q.cursor
|
||||
? {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ effectiveDate: { lt: new Date(q.cursor.time) } },
|
||||
{
|
||||
effectiveDate: new Date(q.cursor.time),
|
||||
sequence: {
|
||||
lt: z.coerce.number().int().positive().parse(q.cursor.key),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
take: q.limit + 1,
|
||||
})
|
||||
.then((rows) =>
|
||||
rows.map((r) => ({ ...r, name: p.name, kind: p.kind, currency: p.currency })),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((a, b) => +b.effectiveDate - +a.effectiveDate || b.sequence - a.sequence)
|
||||
.slice(0, q.limit + 1);
|
||||
const predecessors = candidates.length
|
||||
? await db.$queryRaw<{ id: string; beforeAmount: Prisma.Decimal | null }[]>(
|
||||
Prisma.join(
|
||||
candidates.map(
|
||||
(r) => Prisma.sql`SELECT ${r.id} AS id, (
|
||||
SELECT x.amount FROM Revision x FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
|
||||
WHERE x.positionId = ${r.positionId} AND
|
||||
(x.effectiveDate < ${r.effectiveDate} OR (x.effectiveDate = ${r.effectiveDate} AND x.sequence < ${r.sequence}))
|
||||
ORDER BY x.effectiveDate DESC, x.sequence DESC LIMIT 1
|
||||
) AS beforeAmount`,
|
||||
),
|
||||
' UNION ALL ',
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const prior = new Map(predecessors.map((r) => [r.id, r.beforeAmount]));
|
||||
const rows = candidates.map((r) => ({ ...r, beforeAmount: prior.get(r.id) || null }));
|
||||
const more = rows.length > q.limit;
|
||||
const items = rows.slice(0, q.limit).map((r) => ({
|
||||
...history({
|
||||
id: r.positionId,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
side: '',
|
||||
currency: r.currency,
|
||||
revisions: [r],
|
||||
})[0],
|
||||
before: r.beforeAmount?.toString() || '0',
|
||||
delta: new Decimal(r.amount.toString()).minus(r.beforeAmount?.toString() || '0').toFixed(),
|
||||
}));
|
||||
const last = rows[q.limit - 1];
|
||||
return {
|
||||
items,
|
||||
nextCursor: more ? encodeCursor(last.effectiveDate, last.sequence) : null,
|
||||
revealed,
|
||||
};
|
||||
}
|
||||
|
||||
export function trendInput(query: unknown) {
|
||||
const to = today();
|
||||
const from = new Date(+new Date(to) - 89 * 86400000).toISOString().slice(0, 10);
|
||||
const q = z
|
||||
.object({
|
||||
from: date.default(from),
|
||||
to: date.default(to),
|
||||
grain: z.enum(['day', 'week', 'month']).default('day'),
|
||||
})
|
||||
.strict()
|
||||
.parse(query);
|
||||
const span = (+new Date(q.to) - +new Date(q.from)) / 86400000;
|
||||
const max = q.grain === 'day' ? 365 : q.grain === 'week' ? 1095 : 3652;
|
||||
if (span < 0 || span > max) throw new BadRequestException(`日期范围超出限制(${max + 1} 天)`);
|
||||
return q;
|
||||
}
|
||||
export async function trendData(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
const positions = await db.position.findMany({
|
||||
where: { userId, ...(revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
const ids = positions.map((p) => p.id);
|
||||
const seeds = await latestRevisions(db, ids, toBusinessDate(from));
|
||||
// Only the final balance of each Beijing business day is needed. Rank in MySQL,
|
||||
// rather than shipping every intraday revision to Node or the browser.
|
||||
const daily = ids.length
|
||||
? await db.$queryRaw<(Holding['revisions'][number] & { positionId: string })[]>(Prisma.sql`
|
||||
SELECT id, sequence, positionId, amount, effectiveDate, '' AS notes, 'balance' AS reason FROM (
|
||||
SELECT r.id, r.sequence, r.positionId, r.amount, r.effectiveDate,
|
||||
ROW_NUMBER() OVER (PARTITION BY r.positionId, DATE(DATE_ADD(r.effectiveDate, INTERVAL 8 HOUR))
|
||||
ORDER BY r.effectiveDate DESC, r.sequence DESC) AS rankInDay
|
||||
FROM Revision r JOIN Position p ON p.id = r.positionId
|
||||
WHERE p.userId = ${userId} ${revealed ? Prisma.empty : Prisma.sql`AND p.hidden = false`}
|
||||
AND r.effectiveDate >= ${toBusinessDate(from)} AND r.effectiveDate < ${endOfDay(to)}
|
||||
) ranked WHERE rankInDay = 1 ORDER BY effectiveDate, sequence`)
|
||||
: [];
|
||||
const byId = new Map<string, Holding['revisions']>();
|
||||
for (const r of [...seeds, ...daily]) {
|
||||
const list = byId.get(r.positionId) || [];
|
||||
list.push(r);
|
||||
byId.set(r.positionId, list);
|
||||
}
|
||||
const currencies = [...new Set(positions.map((p) => p.currency))];
|
||||
const user = await db.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { baseCurrency: true },
|
||||
});
|
||||
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
|
||||
const rates = await db.exchangeRate.findMany({
|
||||
where: {
|
||||
userId,
|
||||
currency: { in: currencies },
|
||||
baseCurrency: user.baseCurrency,
|
||||
date: { gte: new Date(from), lte: new Date(to) },
|
||||
},
|
||||
});
|
||||
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
|
||||
return {
|
||||
positions: positions.map((p) => ({ ...p, revisions: byId.get(p.id) || [] })),
|
||||
rates: [...priorRates, ...rates],
|
||||
base: user.baseCurrency,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
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' },
|
||||
}),
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
take: 100,
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
|
||||
+65
-27
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
Query,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
@@ -13,32 +14,49 @@ 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) => {
|
||||
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) ||
|
||||
@@ -51,40 +69,55 @@ export class TransfersController {
|
||||
return { id: existing.id };
|
||||
}
|
||||
}
|
||||
const accounts = await tx.position.findMany({
|
||||
const metadata = 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 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 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 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: before.minus(debit).toFixed(),
|
||||
amount: sourceAfter.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_out',
|
||||
reason: deltas.sourceReason,
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
@@ -93,13 +126,14 @@ export class TransfersController {
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_in',
|
||||
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,
|
||||
@@ -113,9 +147,13 @@ export class TransfersController {
|
||||
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: {},
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
export const currencies = [
|
||||
'CNY',
|
||||
'USD',
|
||||
@@ -64,6 +65,10 @@ export const revisionInput = z
|
||||
'correction',
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
'loan_borrow',
|
||||
'loan_lend',
|
||||
'loan_collect',
|
||||
'loan_repay',
|
||||
])
|
||||
.default('balance'),
|
||||
})
|
||||
@@ -90,7 +95,6 @@ export const positionInput = positionMeta
|
||||
.superRefine((p, c) => {
|
||||
if (
|
||||
(p.kind === 'asset' && p.side !== 'asset') ||
|
||||
(p.kind === 'debt' && p.side !== 'liability') ||
|
||||
(p.kind === 'account' &&
|
||||
['credit_card', 'loan'].includes(p.category) &&
|
||||
p.side !== 'liability')
|
||||
@@ -124,14 +128,33 @@ export const hiddenMenus = z
|
||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||
export const transferInput = z
|
||||
.object({
|
||||
operation: z.enum(['transfer', 'borrow', 'lend', 'collect', 'repay']).default('transfer'),
|
||||
requestId: z.string().uuid().optional(),
|
||||
sourceId: z.string().uuid(),
|
||||
targetId: z.string().uuid(),
|
||||
amount: amount.refine((v) => /[1-9]/.test(v), '转出金额必须大于零'),
|
||||
received: amount.refine((v) => /[1-9]/.test(v), '到账金额必须大于零'),
|
||||
fee: amount.default('0'),
|
||||
fee: z
|
||||
.string()
|
||||
.regex(
|
||||
/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/,
|
||||
'手续费可为负数表示优惠,最多 16 位整数和 8 位小数',
|
||||
)
|
||||
.default('0'),
|
||||
date: businessDate,
|
||||
notes,
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账');
|
||||
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账')
|
||||
.refine((v) => new Decimal(v.fee).neg().lte(v.amount), {
|
||||
message: '手续费优惠不能超过转出金额',
|
||||
path: ['fee'],
|
||||
});
|
||||
export const pairedReasons = [
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
'loan_borrow',
|
||||
'loan_lend',
|
||||
'loan_collect',
|
||||
'loan_repay',
|
||||
];
|
||||
+2
-2
@@ -41,7 +41,7 @@ export function packBackup(b: Backup) {
|
||||
contents['manifest.json'] = JSON.stringify(
|
||||
{
|
||||
format: 'worthpath',
|
||||
version: 5,
|
||||
version: 6,
|
||||
exportedAt: b.exportedAt,
|
||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||
},
|
||||
@@ -116,7 +116,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
||||
const manifest = z
|
||||
.object({
|
||||
format: z.literal('worthpath'),
|
||||
version: z.union([z.literal(3), z.literal(4), z.literal(5)]),
|
||||
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6)]),
|
||||
exportedAt: z.iso.datetime(),
|
||||
files: z
|
||||
.array(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { history, overview, totals, type Holding, type Rate } from '../src/calculation';
|
||||
import { history, overview, totals, trend, type Holding, type Rate } from '../src/calculation';
|
||||
import { positionInput, date, amount, businessDate, toBusinessDate } from '../src/validation';
|
||||
import { validateBackup } from '../src/backup';
|
||||
import { RatesService } from '../src/rates';
|
||||
@@ -44,6 +44,29 @@ const rate = (value: string, day: string): Rate => ({
|
||||
rate: value,
|
||||
source: 'manual',
|
||||
});
|
||||
test('bounded replay seeds the range, preserves same-time order, FX attribution and missing rates', () => {
|
||||
const a = p('asset', 'USD');
|
||||
a.revisions = [
|
||||
{ ...rev('110', '2026-09-02'), sequence: 4 },
|
||||
{ ...rev('100', '2026-08-31'), sequence: 1 },
|
||||
{ ...rev('105', '2026-09-02'), sequence: 3 },
|
||||
];
|
||||
const rates = [rate('7', '2026-08-31'), rate('8', '2026-09-02')];
|
||||
const rows = trend([a], rates, 'CNY', '2026-09-01', '2026-09-03');
|
||||
assert.equal(rows[0].net, '700.00');
|
||||
assert.equal(rows[1].fxChange, '100.00');
|
||||
assert.equal(rows[1].balanceChange, '80.00');
|
||||
assert.equal(rows[2].net, '880.00');
|
||||
assert.equal('items' in rows[0], false);
|
||||
const sampled = trend([a], rates, 'CNY', '2026-09-01', '2026-09-03', 'month');
|
||||
assert.equal(sampled.length, 1);
|
||||
assert.equal(sampled[0].fxChange, '100.00');
|
||||
assert.equal(sampled[0].balanceChange, '80.00');
|
||||
const missing = trend([a], [rate('8', '2026-09-02')], 'CNY', '2026-09-01', '2026-09-03');
|
||||
assert.equal(missing[0].complete, false);
|
||||
assert.equal(missing[1].fxChange, null);
|
||||
assert.equal(missing[2].balanceChange, '0.00');
|
||||
});
|
||||
test('decimal totals and liability sign', () => {
|
||||
const a = p(),
|
||||
b = p('liability');
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readBackupZip, packBackup } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
test('borrow/lend/collect/repay pair balances atomically and survive concurrency and restore', async () => {
|
||||
const db = new PrismaClient(),
|
||||
users: string[] = [];
|
||||
async function user() {
|
||||
const row = await db.user.create({
|
||||
data: { username: 'wp_debt_' + randomUUID(), passwordHash: 'not-a-login-hash' },
|
||||
});
|
||||
users.push(row.id);
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(token).digest('hex'),
|
||||
userId: row.id,
|
||||
expiresAt: new Date(Date.now() + 600000),
|
||||
},
|
||||
});
|
||||
return { id: row.id, token };
|
||||
}
|
||||
async function call(token: string, path: string, method = 'GET', body?: unknown) {
|
||||
const response = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: 'wp_session=' + token,
|
||||
Origin: origin,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
data: response.headers.get('content-type')?.includes('application/zip')
|
||||
? ((await readBackupZip(Buffer.from(await response.arrayBuffer()))) as any)
|
||||
: await response.json(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user();
|
||||
async function position(name: string, kind: string, side: string, amount = '0') {
|
||||
const result = await call(a.token, '/positions', 'POST', {
|
||||
name,
|
||||
kind,
|
||||
side,
|
||||
amount,
|
||||
currency: 'CNY',
|
||||
category: kind === 'debt' ? 'personal' : 'bank',
|
||||
date: '2026-09-01T00:00',
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
return result.data.id as string;
|
||||
}
|
||||
const cash = await position('test account', 'account', 'asset', '1000'),
|
||||
borrowed = await position('test borrowed', 'debt', 'liability'),
|
||||
lent = await position('test lent', 'debt', 'asset');
|
||||
const request = (operation: string, targetId: string, amount: string, fee = '0') => ({
|
||||
requestId: randomUUID(),
|
||||
operation,
|
||||
sourceId: cash,
|
||||
targetId,
|
||||
amount,
|
||||
received: amount,
|
||||
fee,
|
||||
date: '2026-09-02T10:00',
|
||||
});
|
||||
const borrow = request('borrow', borrowed, '100', '2');
|
||||
const parallel = await Promise.all([
|
||||
call(a.token, '/transfers', 'POST', borrow),
|
||||
call(a.token, '/transfers', 'POST', borrow),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
parallel.map((r) => r.status),
|
||||
[201, 201],
|
||||
);
|
||||
assert.equal((await call(a.token, '/overview')).data.net, '998.00');
|
||||
assert.equal(
|
||||
(await call(a.token, '/transfers', 'POST', request('lend', lent, '200', '-2'))).status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call(a.token, '/overview')).data.net, '1000.00');
|
||||
assert.equal(
|
||||
(await call(a.token, '/transfers', 'POST', request('collect', lent, '50'))).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a.token, '/transfers', 'POST', request('repay', borrowed, '30', '1'))).status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call(a.token, '/overview')).data.net, '999.00');
|
||||
const list = (await call(a.token, '/positions')).data;
|
||||
assert.equal(list.find((p: any) => p.id === cash).amount, '919');
|
||||
assert.equal(list.find((p: any) => p.id === borrowed).amount, '70');
|
||||
assert.equal(list.find((p: any) => p.id === lent).amount, '150');
|
||||
assert.equal(list.find((p: any) => p.id === lent).outgoing[0].targetId, cash);
|
||||
const count = await db.revision.count({ where: { position: { userId: a.id } } });
|
||||
assert.equal(
|
||||
(await call(a.token, '/transfers', 'POST', request('repay', borrowed, '71'))).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(a.token, '/transfers', 'POST', request('lend', borrowed, '10'))).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(b.token, '/transfers', 'POST', request('repay', borrowed, '10'))).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(await db.revision.count({ where: { position: { userId: a.id } } }), count);
|
||||
const race = await Promise.all(
|
||||
[1, 2].map(() => call(a.token, '/transfers', 'POST', request('collect', lent, '100'))),
|
||||
);
|
||||
assert.equal(race.filter((r) => r.status === 201).length, 1, JSON.stringify(race));
|
||||
assert.equal(race.filter((r) => r.status === 400 || r.status === 409).length, 1);
|
||||
const paired = (await call(a.token, '/positions/' + cash + '/history')).data.items.find(
|
||||
(r: any) => r.reason === 'loan_collect',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(a.token, '/positions/' + cash + '/revisions/' + paired.id, 'PUT', {
|
||||
amount: '1',
|
||||
date: '2026-09-02T10:00',
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
// A later record at the same business minute can be corrected by sequence.
|
||||
assert.equal(
|
||||
(
|
||||
await call(a.token, '/positions/' + cash + '/revisions', 'POST', {
|
||||
amount: '1020',
|
||||
date: '2026-09-02T10:00',
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const latest = (await call(a.token, '/positions/' + cash + '/history?limit=1')).data.items[0];
|
||||
assert.equal(
|
||||
(
|
||||
await call(a.token, '/positions/' + cash + '/revisions/' + latest.id, 'PUT', {
|
||||
amount: '1021',
|
||||
date: '2026-09-02T10:00',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
const backup = (await call(a.token, '/backup')).data;
|
||||
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 6);
|
||||
assert.equal(backup.transfers.length, 5);
|
||||
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
||||
assert.equal(
|
||||
(await call(b.token, '/backup/import', 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(await call(b.token, '/overview')).data.net,
|
||||
(await call(a.token, '/overview')).data.net,
|
||||
);
|
||||
assert.equal(await db.transfer.count({ where: { userId: b.id } }), 5);
|
||||
const restored = (await call(b.token, '/positions?kind=debt')).data;
|
||||
assert.equal(restored.find((p: any) => p.side === 'asset').amount, '50');
|
||||
assert.equal(restored.find((p: any) => p.side === 'liability').amount, '70');
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: users } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -176,7 +176,11 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const d = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data;
|
||||
const d = {
|
||||
history: (
|
||||
await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)
|
||||
).data.items.reverse(),
|
||||
};
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
@@ -200,7 +204,8 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history[1].delta,
|
||||
(await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)).data.items[0]
|
||||
.delta,
|
||||
'20',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -249,7 +254,9 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const sameDay = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history;
|
||||
const sameDay = (
|
||||
await call('/positions/' + bank + '/history', 'GET', undefined, a.cookie)
|
||||
).data.items.reverse();
|
||||
assert.equal(sameDay.length, 3);
|
||||
assert.equal(sameDay[2].after, '120.1');
|
||||
assert.equal(sameDay[2].delta, '10');
|
||||
@@ -305,9 +312,12 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures
|
||||
),
|
||||
);
|
||||
assert.equal(restored.find((p: { kind: string }) => p.kind === 'debt').outgoing.length, 2);
|
||||
const restoredHistory = restored.find(
|
||||
const restoredAccount = restored.find(
|
||||
(p: { kind: string; side: string }) => p.kind === 'account' && p.side === 'asset',
|
||||
).history;
|
||||
);
|
||||
const restoredHistory = (
|
||||
await call('/positions/' + restoredAccount.id + '/history', 'GET', undefined, b.cookie)
|
||||
).data.items.reverse();
|
||||
assert.deepEqual(
|
||||
restoredHistory.map((h: { after: string }) => h.after),
|
||||
sameDay.map((h: { after: string }) => h.after),
|
||||
|
||||
@@ -109,7 +109,9 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const history = (await call('/positions/' + hidden, 'GET', undefined, a.cookie)).data.history;
|
||||
const history = (
|
||||
await call('/positions/' + hidden + '/history', 'GET', undefined, a.cookie)
|
||||
).data.items.reverse();
|
||||
assert.deepEqual(
|
||||
history.map((h: { time: string }) => h.time),
|
||||
['2026-09-01T09:17', '2026-09-01T09:18'],
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { toBusinessDate } from '../src/validation';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
test('bounded APIs preserve seeds, predecessor balances, pagination, archive and FX scope', async () => {
|
||||
const db = new PrismaClient();
|
||||
const user = await db.user.create({
|
||||
data: { username: 'wp_query_' + randomUUID(), passwordHash: 'not-a-login-hash' },
|
||||
});
|
||||
const token = randomBytes(32).toString('hex'),
|
||||
sessionId = createHash('sha256').update(token).digest('hex');
|
||||
let restoredUserId: string | undefined;
|
||||
async function call(path: string) {
|
||||
const response = await fetch(base + path, { headers: { Cookie: 'wp_session=' + token } });
|
||||
return { status: response.status, data: await response.json() };
|
||||
}
|
||||
try {
|
||||
await db.session.create({
|
||||
data: { id: sessionId, userId: user.id, expiresAt: new Date(Date.now() + 600000) },
|
||||
});
|
||||
const make = (name: string, currency = 'CNY', hidden = false) =>
|
||||
db.position.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
currency,
|
||||
hidden,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
const a = await make('archive'),
|
||||
b = await make('foreign', 'USD'),
|
||||
hidden = await make('hidden', 'CNY', true);
|
||||
const revision = (positionId: string, amount: string, date: string) =>
|
||||
db.revision.create({
|
||||
data: {
|
||||
positionId,
|
||||
amount,
|
||||
effectiveDate: toBusinessDate(date),
|
||||
notes: '',
|
||||
reason: 'balance',
|
||||
},
|
||||
});
|
||||
await revision(a.id, '100', '2026-08-31T23:59');
|
||||
await revision(a.id, '119', '2026-09-02T00:01');
|
||||
await revision(a.id, '120', '2026-09-02T00:01');
|
||||
await revision(a.id, '110', '2026-09-01T00:01'); // Late insert does not replace the latest balance.
|
||||
await revision(b.id, '10', '2026-08-31T23:59');
|
||||
await revision(hidden.id, '3', '2026-09-01T00:01');
|
||||
await db.position.update({ where: { id: a.id }, data: { archived: true } });
|
||||
await db.exchangeRate.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
currency: 'USD',
|
||||
baseCurrency: 'CNY',
|
||||
date: new Date('2026-09-02'),
|
||||
rate: '7',
|
||||
source: 'manual',
|
||||
},
|
||||
});
|
||||
const list = (await call('/positions?kind=account')).data;
|
||||
assert.equal(list.length, 2);
|
||||
assert.equal(list.find((p: any) => p.id === a.id).amount, '120');
|
||||
assert.equal('history' in list[0], false);
|
||||
assert.equal('revisions' in list[0], false);
|
||||
const current = (await call('/overview')).data;
|
||||
assert.equal(current.net, '190.00');
|
||||
assert.equal('trend' in current, false);
|
||||
const trend = (await call('/trend?from=2026-09-01&to=2026-09-03')).data.items;
|
||||
assert.equal(trend[0].complete, false);
|
||||
assert.equal(trend[1].net, '190.00');
|
||||
assert.equal(trend[1].balanceChange, null);
|
||||
assert.equal(trend[2].fxChange, '0.00');
|
||||
assert.equal('items' in trend[0], false);
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
do {
|
||||
const response = await call(
|
||||
'/positions/' + a.id + '/history?limit=1' + (cursor ? '&cursor=' + cursor : ''),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const row = response.data.items[0];
|
||||
assert.equal(seen.has(row.id), false);
|
||||
seen.add(row.id);
|
||||
if (row.after === '120') {
|
||||
assert.equal(row.before, '119');
|
||||
assert.equal(row.delta, '1');
|
||||
}
|
||||
if (row.after === '119') {
|
||||
assert.equal(row.before, '110');
|
||||
assert.equal(row.delta, '9');
|
||||
}
|
||||
if (row.after === '110') assert.equal(row.before, '100');
|
||||
cursor = response.data.nextCursor;
|
||||
} while (cursor);
|
||||
assert.equal(seen.size, 4);
|
||||
const bounded = (await call('/positions/' + a.id + '/history?from=2026-09-02&to=2026-09-02'))
|
||||
.data.items;
|
||||
assert.equal(bounded[1].before, '110');
|
||||
assert.equal((await call('/history?limit=101')).status, 400);
|
||||
assert.equal((await call('/history?cursor=bad')).status, 400);
|
||||
assert.equal((await call('/trend?from=2020-01-01&to=2026-09-03')).status, 400);
|
||||
assert.equal((await call('/positions/' + hidden.id + '/history')).status, 404);
|
||||
await db.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { revealUntil: new Date(Date.now() + 60000) },
|
||||
});
|
||||
assert.equal((await call('/overview')).data.net, '193.00');
|
||||
await db.user.update({ where: { id: user.id }, data: { baseCurrency: 'USD' } });
|
||||
assert.equal((await call('/overview')).data.complete, false);
|
||||
await db.exchangeRate.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
currency: 'CNY',
|
||||
baseCurrency: 'USD',
|
||||
date: new Date('2026-09-01'),
|
||||
rate: '0.1',
|
||||
source: 'manual',
|
||||
},
|
||||
});
|
||||
assert.equal((await call('/overview')).data.net, '22.30');
|
||||
await db.user.update({ where: { id: user.id }, data: { baseCurrency: 'CNY' } });
|
||||
await db.position.update({ where: { id: a.id }, data: { archived: false } });
|
||||
const target = await make('discount target');
|
||||
await revision(target.id, '0', '2026-09-01T00:00');
|
||||
const origin =
|
||||
process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
const transfer = {
|
||||
requestId: randomUUID(),
|
||||
sourceId: a.id,
|
||||
targetId: target.id,
|
||||
amount: '100',
|
||||
received: '100',
|
||||
fee: '-2',
|
||||
date: '2026-09-03T00:01',
|
||||
};
|
||||
const post = async (path: string, body: unknown, cookie = token) => {
|
||||
const response = await fetch(base + path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: 'wp_session=' + cookie,
|
||||
Origin: origin,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: response.status, data: await response.json() };
|
||||
};
|
||||
assert.equal((await post('/transfers', transfer)).status, 201);
|
||||
assert.equal((await post('/transfers', transfer)).status, 201);
|
||||
const discounted = (await call('/positions')).data;
|
||||
assert.equal(discounted.find((p: any) => p.id === a.id).amount, '22');
|
||||
assert.equal(discounted.find((p: any) => p.id === target.id).amount, '100');
|
||||
assert.equal(
|
||||
(await post('/transfers', { ...transfer, requestId: randomUUID(), fee: '-101' })).status,
|
||||
400,
|
||||
);
|
||||
const exported = await fetch(base + '/backup', { headers: { Cookie: 'wp_session=' + token } });
|
||||
assert.equal(exported.status, 200);
|
||||
const backup = (await readBackupZip(Buffer.from(await exported.arrayBuffer()))) as any;
|
||||
assert.equal(backup.transfers[0].fee, '-2');
|
||||
const restored = await db.user.create({
|
||||
data: { username: 'wp_query_restore_' + randomUUID(), passwordHash: 'not-a-login-hash' },
|
||||
});
|
||||
restoredUserId = restored.id;
|
||||
const restoredToken = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(restoredToken).digest('hex'),
|
||||
userId: restored.id,
|
||||
expiresAt: new Date(Date.now() + 600000),
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await post('/backup/import', { confirmed: true, backup }, restoredToken)).status,
|
||||
201,
|
||||
);
|
||||
const restoredTransfer = await db.transfer.findFirstOrThrow({ where: { userId: restored.id } });
|
||||
assert.equal(restoredTransfer.fee.toString(), '-2');
|
||||
const restoredBalance = await db.revision.findUniqueOrThrow({
|
||||
where: { id: restoredTransfer.sourceRevisionId },
|
||||
});
|
||||
assert.equal(restoredBalance.amount.toString(), '22');
|
||||
} finally {
|
||||
if (restoredUserId) await db.user.delete({ where: { id: restoredUserId } });
|
||||
await db.user.delete({ where: { id: user.id } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -103,8 +103,8 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
assert.equal(rows.find((p: any) => p.id === sourceId).amount, '74');
|
||||
assert.equal(rows.find((p: any) => p.id === targetId).amount, '35');
|
||||
assert.equal((await call('/overview', a.cookie)).data.net, '109.00');
|
||||
assert.equal((await call('/transfers', b.cookie)).data.length, 0);
|
||||
const revision = rows.find((p: any) => p.id === sourceId).history.at(-1);
|
||||
assert.equal((await call('/transfers', b.cookie)).data.items.length, 0);
|
||||
const revision = (await call('/positions/' + sourceId + '/history', a.cookie)).data.items[0];
|
||||
assert.equal(
|
||||
(
|
||||
await call(`/positions/${sourceId}/revisions/${revision.id}`, a.cookie, 'PUT', {
|
||||
@@ -188,7 +188,11 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(race.filter((r) => r.status === 201).length, 1);
|
||||
assert.equal(
|
||||
race.filter((r) => r.status === 201).length,
|
||||
1,
|
||||
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
|
||||
);
|
||||
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
|
||||
assert.equal(
|
||||
(
|
||||
@@ -249,7 +253,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
category: 'bank',
|
||||
hidden: true,
|
||||
});
|
||||
const visibleTransfers = (await call('/transfers', a.cookie)).data;
|
||||
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
|
||||
assert.equal(visibleTransfers.length, 2);
|
||||
assert.ok(
|
||||
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.`);
|
||||
+765
-312
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,160 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { type Position, nowMinute, money, requestUuid } from './api';
|
||||
import { t as tr } from './i18n';
|
||||
export type DebtOperation = 'borrow' | 'lend' | 'collect' | 'repay';
|
||||
export const operationLabel = (operation: string) =>
|
||||
tr(
|
||||
(
|
||||
{
|
||||
borrow: '借入到账',
|
||||
lend: '借出付款',
|
||||
collect: '收回借款',
|
||||
repay: '偿还债务',
|
||||
transfer: '账户转账',
|
||||
} as Record<string, string>
|
||||
)[operation] || operation,
|
||||
);
|
||||
export function DebtPaymentForm({
|
||||
accounts,
|
||||
debts,
|
||||
initialAccount,
|
||||
initialDebt,
|
||||
initialOperation,
|
||||
busy,
|
||||
submit,
|
||||
}: {
|
||||
accounts: Position[];
|
||||
debts: Position[];
|
||||
initialAccount?: string;
|
||||
initialDebt?: string;
|
||||
initialOperation?: DebtOperation;
|
||||
busy: boolean;
|
||||
submit: (value: unknown) => void;
|
||||
}) {
|
||||
const [requestId] = useState(requestUuid);
|
||||
const [accountId, setAccount] = useState(initialAccount || accounts[0]?.id || '');
|
||||
const [debtId, setDebt] = useState(initialDebt || debts[0]?.id || '');
|
||||
const debt = debts.find((p) => p.id === debtId),
|
||||
account = accounts.find((p) => p.id === accountId);
|
||||
const operations: DebtOperation[] =
|
||||
debt?.side === 'asset' ? ['collect', 'lend'] : ['repay', 'borrow'];
|
||||
const [chosen, setChosen] = useState<DebtOperation | undefined>(initialOperation);
|
||||
const operation = chosen && operations.includes(chosen) ? chosen : operations[0];
|
||||
const [amount, setAmount] = useState('');
|
||||
const same = debt?.currency === account?.currency;
|
||||
function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
submit(Object.fromEntries(new FormData(event.currentTarget)));
|
||||
}
|
||||
if (!accounts.length || !debts.length)
|
||||
return <p>{tr('请先创建启用的资产账户和借入或借出债务。')}</p>;
|
||||
return (
|
||||
<form onSubmit={save} className="transfer-form">
|
||||
<input type="hidden" name="requestId" value={requestId} />
|
||||
<label>
|
||||
{tr('债务')}
|
||||
<select
|
||||
name="targetId"
|
||||
value={debtId}
|
||||
onChange={(event) => {
|
||||
setDebt(event.target.value);
|
||||
setChosen(undefined);
|
||||
}}
|
||||
>
|
||||
{debts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} · {tr(p.side === 'asset' ? '借出(应收)' : '借入(应付)')} ·{' '}
|
||||
{money(p.amount, p.currency)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr('操作')}
|
||||
<select
|
||||
name="operation"
|
||||
value={operation}
|
||||
onChange={(event) => setChosen(event.target.value as DebtOperation)}
|
||||
>
|
||||
{operations.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{operationLabel(value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{tr(operation === 'borrow' || operation === 'collect' ? '收款账户' : '付款账户')}
|
||||
<select
|
||||
name="sourceId"
|
||||
value={accountId}
|
||||
onChange={(event) => setAccount(event.target.value)}
|
||||
>
|
||||
{accounts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} · {money(p.amount, p.currency)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">
|
||||
{tr('账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。')}
|
||||
</p>
|
||||
<label>
|
||||
{tr('本次账户金额')} ({account?.currency})
|
||||
<input
|
||||
name="amount"
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('本次债务本金')} ({debt?.currency})
|
||||
{same ? (
|
||||
<input name="received" required readOnly value={amount} />
|
||||
) : (
|
||||
<input
|
||||
name="received"
|
||||
key={debtId}
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
{tr('手续费 / 优惠')} ({account?.currency})
|
||||
<input
|
||||
name="fee"
|
||||
required
|
||||
defaultValue="0"
|
||||
inputMode="decimal"
|
||||
pattern="-?(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||
/>
|
||||
</label>
|
||||
<p className="muted">
|
||||
{tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')}
|
||||
</p>
|
||||
<label>
|
||||
{tr('业务时间(北京时间,精确到分钟)')}
|
||||
<input
|
||||
name="date"
|
||||
type="datetime-local"
|
||||
required
|
||||
defaultValue={nowMinute()}
|
||||
max={nowMinute()}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{tr('备注')}
|
||||
<textarea className="note-input" name="notes" maxLength={2000} rows={2} />
|
||||
</label>
|
||||
<button className="primary" disabled={busy}>
|
||||
{busy ? tr('正在保存…') : operationLabel(operation)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t as tr } from './i18n';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { api } from './api';
|
||||
type Icon = { id: string; name: string; shared: boolean; source: string | null };
|
||||
@@ -55,15 +56,15 @@ export function IconLibrary({
|
||||
setError('');
|
||||
setNotice('');
|
||||
if (!image) {
|
||||
setError('请选择图片');
|
||||
setError(tr('请选择图片'));
|
||||
return;
|
||||
}
|
||||
if (!name.trim() || (shared && (!/\p{Script=Han}/u.test(name) || !confirmed))) {
|
||||
setError('请填写名称;共享图标须含中文并确认公开');
|
||||
setError(tr('请填写名称;共享图标须含中文并确认公开'));
|
||||
return;
|
||||
}
|
||||
if (image.size > 2 * 1024 * 1024) {
|
||||
setError('图片不能超过 2 MB');
|
||||
setError(tr('图片不能超过 2 MB'));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -79,10 +80,12 @@ export function IconLibrary({
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const v = await res.json();
|
||||
if (!res.ok) throw Error(v.message || '上传失败');
|
||||
if (!res.ok) throw Error(v.message || tr('上传失败'));
|
||||
if (picker) select(v.id);
|
||||
setNotice(
|
||||
shared ? '图标已发布,所有登录用户均可搜索和使用' : '图标已存入我的图标,仅自己可见',
|
||||
shared
|
||||
? tr('图标已发布,所有登录用户均可搜索和使用')
|
||||
: tr('图标已存入我的图标,仅自己可见'),
|
||||
);
|
||||
setName('');
|
||||
setConfirmed(false);
|
||||
@@ -99,7 +102,7 @@ export function IconLibrary({
|
||||
return (
|
||||
<section
|
||||
className="icon-library"
|
||||
aria-label="账户图标库"
|
||||
aria-label={tr('账户图标库')}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
@@ -109,31 +112,31 @@ export function IconLibrary({
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<h2>{picker ? '选择账户图标' : '图标库'}</h2>
|
||||
<h2>{picker ? tr('选择账户图标') : tr('图标库')}</h2>
|
||||
<p className="muted">
|
||||
搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。
|
||||
{tr('搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。')}
|
||||
</p>
|
||||
{picker && (
|
||||
<div className="icon-selection">
|
||||
{selected ? (
|
||||
<>
|
||||
<img src={iconUrl(selected)} alt="当前账户图标" />
|
||||
<span>已选择图标</span>
|
||||
<img src={iconUrl(selected)} alt={tr('当前账户图标')} />
|
||||
<span>{tr('已选择图标')}</span>
|
||||
<button type="button" className="text" onClick={() => select('')}>
|
||||
移除图标
|
||||
{tr('移除图标')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>使用默认账户图标</span>
|
||||
<span>{tr('使用默认账户图标')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<label>
|
||||
搜索图标名称
|
||||
{tr('搜索图标名称')}
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
placeholder="例如:工商银行、支付宝"
|
||||
placeholder={tr('例如:工商银行、支付宝')}
|
||||
onChange={(e) => {
|
||||
search(e.target.value);
|
||||
setPage(1);
|
||||
@@ -142,7 +145,7 @@ export function IconLibrary({
|
||||
/>
|
||||
</label>
|
||||
{loading ? (
|
||||
<p role="status">正在加载图标…</p>
|
||||
<p role="status">{tr('正在加载图标…')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="icon-grid">
|
||||
@@ -157,27 +160,30 @@ export function IconLibrary({
|
||||
>
|
||||
<img src={iconUrl(i.id)} alt="" loading="lazy" />
|
||||
<span>{i.name}</span>
|
||||
<small>{i.shared ? '共享' : '仅自己'}</small>
|
||||
<small>{i.shared ? tr('共享') : tr('仅自己')}</small>
|
||||
</button>
|
||||
) : (
|
||||
<div className="icon-option" key={i.id}>
|
||||
<img src={iconUrl(i.id)} alt="" loading="lazy" />
|
||||
<span>{i.name}</span>
|
||||
<small>{i.shared ? '共享' : '仅自己'}</small>
|
||||
<small>{i.shared ? tr('共享') : tr('仅自己')}</small>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{!items.length && <p>没有匹配图标,可以在下方上传。</p>}
|
||||
{!items.length && <p>{tr('没有匹配图标,可以在下方上传。')}</p>}
|
||||
<div className="icon-pagination">
|
||||
<span>共 {total} 个图标</span>
|
||||
<span>
|
||||
{tr('共')}
|
||||
{total} {tr('个图标')}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((n) => n - 1)}
|
||||
>
|
||||
上一页
|
||||
{tr('上一页')}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
@@ -185,24 +191,24 @@ export function IconLibrary({
|
||||
disabled={page * 60 >= total}
|
||||
onClick={() => setPage((n) => n + 1)}
|
||||
>
|
||||
下一页
|
||||
{tr('下一页')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{onSelect && (
|
||||
<button type="button" className="primary" onClick={() => onSelect(selected)}>
|
||||
应用所选图标
|
||||
{tr('应用所选图标')}
|
||||
</button>
|
||||
)}
|
||||
<details className="icon-upload">
|
||||
<summary>上传我的图标 / 导入共享图标</summary>
|
||||
<summary>{tr('上传我的图标 / 导入共享图标')}</summary>
|
||||
<label>
|
||||
图标名称(共享时必须含中文)
|
||||
{tr('图标名称(共享时必须含中文)')}
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={100} />
|
||||
</label>
|
||||
<label>
|
||||
图片文件(PNG、JPG、WebP,最大 2 MB)
|
||||
{tr('图片文件(PNG、JPG、WebP,最大 2 MB)')}
|
||||
<input ref={file} type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</label>
|
||||
<label className="check-line">
|
||||
@@ -214,7 +220,7 @@ export function IconLibrary({
|
||||
setConfirmed(false);
|
||||
}}
|
||||
/>
|
||||
发布到共享图标库
|
||||
{tr('发布到共享图标库')}
|
||||
</label>
|
||||
{shared && (
|
||||
<label className="check-line">
|
||||
@@ -223,19 +229,19 @@ export function IconLibrary({
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用
|
||||
{tr('确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用')}
|
||||
</label>
|
||||
)}
|
||||
<button className="primary" type="button" disabled={busy} onClick={() => void upload()}>
|
||||
{busy ? '正在上传…' : shared ? '导入共享图标' : '上传私有图标'}
|
||||
{busy ? tr('正在上传…') : shared ? tr('导入共享图标') : tr('上传私有图标')}
|
||||
</button>
|
||||
</details>
|
||||
{error && (
|
||||
<p role="alert" className="icon-error">
|
||||
{error}
|
||||
{tr(error)}
|
||||
</p>
|
||||
)}
|
||||
{notice && <p role="status">{notice}</p>}
|
||||
{notice && <p role="status">{tr(notice)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t as tr } from './i18n';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconLibrary, iconUrl } from './IconLibrary';
|
||||
@@ -39,13 +40,13 @@ export function IconPicker({ initialId }: { initialId?: string | null }) {
|
||||
return (
|
||||
<div className="account-icon-field">
|
||||
<input type="hidden" name="iconId" value={id} />
|
||||
{id && <img src={iconUrl(id)} alt="已选账户图标" />}
|
||||
{id && <img src={iconUrl(id)} alt={tr('已选账户图标')} />}
|
||||
<button type="button" className="secondary" onClick={() => setOpen(true)}>
|
||||
{id ? '更换图标' : '选择图标'}
|
||||
{id ? tr('更换图标') : tr('选择图标')}
|
||||
</button>
|
||||
{id && (
|
||||
<button type="button" className="text" onClick={() => setId('')}>
|
||||
移除
|
||||
{tr('移除')}
|
||||
</button>
|
||||
)}
|
||||
{open &&
|
||||
@@ -61,12 +62,12 @@ export function IconPicker({ initialId }: { initialId?: string | null }) {
|
||||
className="icon-picker-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="选择账户图标"
|
||||
aria-label={tr('选择账户图标')}
|
||||
>
|
||||
<div className="panel-title">
|
||||
<h2>账户图标</h2>
|
||||
<h2>{tr('账户图标')}</h2>
|
||||
<button type="button" className="secondary" onClick={() => setOpen(false)}>
|
||||
关闭图标选择
|
||||
{tr('关闭图标选择')}
|
||||
</button>
|
||||
</div>
|
||||
<IconLibrary
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { t as tr } from './i18n';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { nowMinute, money, requestUuid, type Position } from './api';
|
||||
export function TransferForm({
|
||||
accounts,
|
||||
busy,
|
||||
submit,
|
||||
initialSourceId,
|
||||
}: {
|
||||
accounts: Position[];
|
||||
busy: boolean;
|
||||
submit: (data: unknown) => void;
|
||||
initialSourceId?: string;
|
||||
}) {
|
||||
const [requestId] = useState(() => requestUuid());
|
||||
const [sourceId, setSource] = useState(accounts[0]?.id || ''),
|
||||
[targetId, setTarget] = useState(accounts[1]?.id || ''),
|
||||
const [sourceId, setSource] = useState(initialSourceId || accounts[0]?.id || ''),
|
||||
[targetId, setTarget] = useState(
|
||||
accounts.find((p) => p.id !== (initialSourceId || accounts[0]?.id))?.id || '',
|
||||
),
|
||||
[amount, setAmount] = useState('');
|
||||
const source = accounts.find((p) => p.id === sourceId),
|
||||
target = accounts.find((p) => p.id === targetId);
|
||||
@@ -21,15 +26,17 @@ export function TransferForm({
|
||||
submit(Object.fromEntries(new FormData(e.currentTarget)));
|
||||
}
|
||||
if (accounts.length < 2)
|
||||
return <p>请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。</p>;
|
||||
return <p>{tr('请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。')}</p>;
|
||||
return (
|
||||
<form onSubmit={save} className="transfer-form">
|
||||
<input type="hidden" name="requestId" value={requestId} />
|
||||
<p className="muted">
|
||||
双方余额同时更新。手续费从转出账户额外扣除;转账时间不能早于双方最新余额记录。
|
||||
{tr(
|
||||
'双方余额同时更新。正数手续费额外扣除,负数表示优惠抵扣(不超过转出金额);转账时间不能早于双方最新余额记录。',
|
||||
)}
|
||||
</p>
|
||||
<label>
|
||||
转出账户
|
||||
{tr('转出账户')}
|
||||
<select name="sourceId" value={sourceId} onChange={(e) => setSource(e.target.value)}>
|
||||
{accounts.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
@@ -39,7 +46,7 @@ export function TransferForm({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
转入账户
|
||||
{tr('转入账户')}
|
||||
<select name="targetId" value={targetId} onChange={(e) => setTarget(e.target.value)}>
|
||||
{accounts.map((p) => (
|
||||
<option key={p.id} value={p.id} disabled={p.id === sourceId}>
|
||||
@@ -50,7 +57,8 @@ export function TransferForm({
|
||||
</label>
|
||||
<div className="form-columns">
|
||||
<label>
|
||||
转出金额({source?.currency})
|
||||
{tr('转出金额(')}
|
||||
{source?.currency})
|
||||
<input
|
||||
name="amount"
|
||||
required
|
||||
@@ -61,7 +69,8 @@ export function TransferForm({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
实际到账({target?.currency})
|
||||
{tr('实际到账(')}
|
||||
{target?.currency})
|
||||
{same ? (
|
||||
<input name="received" required readOnly value={amount} />
|
||||
) : (
|
||||
@@ -76,20 +85,24 @@ export function TransferForm({
|
||||
</label>
|
||||
</div>
|
||||
{!same && (
|
||||
<p className="notice warning">跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。</p>
|
||||
<p className="notice warning">
|
||||
{tr('跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。')}
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
额外手续费({source?.currency})
|
||||
{tr('手续费 / 优惠(')}
|
||||
{source?.currency}
|
||||
{tr(',优惠填负数)')}
|
||||
<input
|
||||
name="fee"
|
||||
required
|
||||
defaultValue="0"
|
||||
inputMode="decimal"
|
||||
pattern="(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||
pattern="-?(0|[1-9][0-9]{0,15})(\.[0-9]{1,8})?"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
转账时间(北京时间)
|
||||
{tr('转账时间(北京时间)')}
|
||||
<input
|
||||
name="date"
|
||||
type="datetime-local"
|
||||
@@ -99,11 +112,11 @@ export function TransferForm({
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
{tr('备注')}
|
||||
<textarea className="note-input" name="notes" maxLength={2000} rows={2} />
|
||||
</label>
|
||||
<button className="primary" disabled={busy || sourceId === targetId}>
|
||||
{busy ? '正在转账…' : '确认转账'}
|
||||
{busy ? tr('正在转账…') : tr('确认转账')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
+11
-4
@@ -55,8 +55,9 @@ export type Position = {
|
||||
notes: string;
|
||||
archived: boolean;
|
||||
hidden: boolean;
|
||||
history: History[];
|
||||
outgoing: { targetId: string }[];
|
||||
converted: string | null;
|
||||
rateDate: string | null;
|
||||
outgoing: { targetId: string; target: { name: string; kind: string } }[];
|
||||
};
|
||||
export type Total = {
|
||||
date: string;
|
||||
@@ -75,6 +76,7 @@ export type Overview = Total & {
|
||||
recent: History[];
|
||||
items: {
|
||||
id: string;
|
||||
kind: string;
|
||||
converted: string | null;
|
||||
rateDate: string | null;
|
||||
currency: string;
|
||||
@@ -107,8 +109,11 @@ export function money(v: string, currency: string) {
|
||||
|
||||
export type Transfer = {
|
||||
id: string;
|
||||
source: { name: string };
|
||||
target: { name: string };
|
||||
operation: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
source: { name: string; kind: string };
|
||||
target: { name: string; kind: string };
|
||||
sourceCurrency: string;
|
||||
targetCurrency: string;
|
||||
amount: string;
|
||||
@@ -126,3 +131,5 @@ export function requestUuid() {
|
||||
const hex = Array.from(bytes, (v) => v.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export type PageResult<T> = { items: T[]; nextCursor: string | null; revealed: boolean };
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useSyncExternalStore, useEffect } from 'react';
|
||||
import english from './locales/en.json';
|
||||
import traditional from './locales/zh-Hant.json';
|
||||
export type Locale = 'zh-Hans' | 'en' | 'zh-Hant';
|
||||
const storageKey = 'worthpath.locale';
|
||||
export const localeOptions: { value: Locale; label: string }[] = [
|
||||
{ value: 'zh-Hans', label: '简体中文' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh-Hant', label: '繁體中文' },
|
||||
];
|
||||
export const validLocale = (value: unknown): value is Locale =>
|
||||
localeOptions.some((option) => option.value === value);
|
||||
function initialLocale(): Locale {
|
||||
try {
|
||||
const value = localStorage.getItem(storageKey);
|
||||
return validLocale(value) ? value : 'zh-Hans';
|
||||
} catch {
|
||||
return 'zh-Hans';
|
||||
}
|
||||
}
|
||||
let locale = initialLocale();
|
||||
const listeners = new Set<() => void>();
|
||||
const originalPhrases = new Map<string, string>();
|
||||
for (const dictionary of [english, traditional])
|
||||
for (const [key, value] of Object.entries(dictionary))
|
||||
if (!originalPhrases.has(value)) originalPhrases.set(value, key);
|
||||
const compoundPhrases = Object.keys(english)
|
||||
.filter((key) => key.length >= 6)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
export function setLocale(value: Locale) {
|
||||
if (!validLocale(value)) return;
|
||||
locale = value;
|
||||
try {
|
||||
localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
/* Session-only preference if storage is unavailable. */
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
export function translate(text: string, language: Locale): string {
|
||||
const original = originalPhrases.get(text) || text;
|
||||
if (language === 'zh-Hans') return original;
|
||||
const dictionary: Record<string, string> = language === 'en' ? english : traditional;
|
||||
if (dictionary[original] !== undefined) return dictionary[original];
|
||||
// Server validation messages can include a field prefix or a date. Translate
|
||||
// only known application phrases; user names and notes never pass through t().
|
||||
let result = original;
|
||||
for (const key of compoundPhrases) {
|
||||
if (result.includes(key)) result = result.split(key).join(dictionary[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export const t = (text: string) => translate(text, locale);
|
||||
export function useLocale() {
|
||||
const value = useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
() => locale,
|
||||
() => 'zh-Hans' as Locale,
|
||||
);
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = value;
|
||||
document.title = 'WorthPath · ' + translate('我的资产空间', value);
|
||||
}, [value]);
|
||||
return value;
|
||||
}
|
||||
export function LanguagePicker() {
|
||||
const value = useLocale();
|
||||
return (
|
||||
<label className="language-picker">
|
||||
<span>{t('语言')}</span>
|
||||
<select
|
||||
aria-label={t('语言')}
|
||||
value={value}
|
||||
onChange={(event) => setLocale(event.target.value as Locale)}
|
||||
>
|
||||
{localeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"资产总览": "Overview",
|
||||
"账户": "Accounts",
|
||||
"独立资产": "Independent assets",
|
||||
"债务": "Debts",
|
||||
"变化记录": "Activity",
|
||||
"设置与备份": "Settings & backups",
|
||||
"银行账户": "Bank account",
|
||||
"现金": "Cash",
|
||||
"投资账户": "Investment account",
|
||||
"信用卡": "Credit card",
|
||||
"贷款账户": "Loan account",
|
||||
"其他": "Other",
|
||||
"房产": "Property",
|
||||
"车辆": "Vehicle",
|
||||
"贵金属": "Precious metals",
|
||||
"其他资产": "Other assets",
|
||||
"房贷": "Mortgage",
|
||||
"车贷": "Car loan",
|
||||
"个人借款": "Personal borrowing",
|
||||
"其他债务": "Other debts",
|
||||
"备注": "Notes",
|
||||
"清空本账号数据": "Clear this account's data",
|
||||
"关闭": "Close",
|
||||
"等待更多历史记录": "Waiting for more history",
|
||||
"至少两个日期的完整余额和汇率记录,才能绘制趋势。": "At least two dates with complete balances and exchange rates are needed to draw a trend.",
|
||||
"总资产": "Total assets",
|
||||
"总负债": "Total liabilities",
|
||||
"净资产": "Net worth",
|
||||
"资产、负债和净资产历史趋势": "Historical assets, liabilities and net worth",
|
||||
"缺失汇率的日期未绘制,曲线在该处断开。": "Dates with missing exchange rates are omitted, leaving gaps in the chart.",
|
||||
"操作失败": "Operation failed",
|
||||
"长时间无操作,已自动退出登录": "Signed out automatically due to inactivity",
|
||||
"新增": "Add ",
|
||||
"WorthPath · 正在连接你的资产空间…": "WorthPath · Connecting to your asset workspace…",
|
||||
"让每一步积累,": "Give every step forward",
|
||||
"都有清晰的方向。": "a clear direction.",
|
||||
"在一个空间里,连接你的账户、资产与债务。": "Connect your accounts, assets and debts in one place.",
|
||||
"看见当下,也看见净资产的每一次变化。": "See your finances today and every change in net worth.",
|
||||
"关注长期积累": "Focus on long-term progress",
|
||||
"从第一份真实资产开始": "Start with your first real asset",
|
||||
"个人资产管理 · 多币种 · 历史追踪": "Personal asset management · Multiple currencies · Full history",
|
||||
"开始你的资产之路": "Start your asset journey",
|
||||
"创建账号": "Create account",
|
||||
"欢迎回来": "Welcome back",
|
||||
"建立属于你的私人资产空间": "Create your private asset workspace",
|
||||
"登录以查看你的资产与负债": "Sign in to view your assets and liabilities",
|
||||
"账号": "Username",
|
||||
"输入账号(至少 3 个字符)": "Enter a username (at least 3 characters)",
|
||||
"密码": "Password",
|
||||
"至少 10 个字符,最多 72 字节": "At least 10 characters, up to 72 bytes",
|
||||
"正在处理…": "Processing…",
|
||||
"注册并进入": "Register & continue",
|
||||
"登录": "Sign in",
|
||||
"已有账号?": "Already have an account?",
|
||||
"还没有账号?": "Don't have an account?",
|
||||
"个人数据按登录身份隔离": "Your data is isolated by account",
|
||||
"我的资产空间": "My asset workspace",
|
||||
"个人账户 ·": "Personal account ·",
|
||||
"退出登录": "Sign out",
|
||||
"个人财务 /": "Personal finances /",
|
||||
"本位币": "Base currency",
|
||||
"刷新数据": "Refresh data",
|
||||
"当前包含隐藏项目 · 验证 5 分钟后自动锁定": "Hidden items included · Access locks automatically after 5 minutes",
|
||||
"当前不包含隐藏账户、资产和债务,净资产按此范围计算": "Hidden accounts, assets and debts are excluded from the displayed net worth",
|
||||
"隐藏项目已锁定": "Hidden items locked",
|
||||
"锁定隐藏项目": "Lock hidden items",
|
||||
"显示隐藏资产(验证密码)": "Show hidden assets (verify password)",
|
||||
"账户图标": "Account icon",
|
||||
"你的财富全貌,从这里开始。": "Your financial picture starts here.",
|
||||
"让数据始终可控,让换算清晰可靠。": "Keep your data under control and conversions clear.",
|
||||
"保留原币金额,追踪长期变化。": "Keep original currency values and track long-term changes.",
|
||||
"账户间转账": "Transfer between accounts",
|
||||
"正在刷新数据…": "Refreshing data…",
|
||||
"开始": "From",
|
||||
"结束": "To",
|
||||
"日(最多366天)": "Daily (up to 366 days)",
|
||||
"周(最多3年)": "Weekly (up to 3 years)",
|
||||
"月(最多10年)": "Monthly (up to 10 years)",
|
||||
"默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。": "Defaults to 90 days. Weeks run Monday–Sunday; months use calendar boundaries. Balances are sampled at period end and daily changes are added within each period. Partial periods use the selected range.",
|
||||
"缺少": "Missing ",
|
||||
"到": " to ",
|
||||
"的汇率。以下仅展示已能换算的部分,不能作为完整总额。": " exchange rates. These are partial totals of convertible balances.",
|
||||
"设置汇率": "Exchange rate settings",
|
||||
"(部分)": " (partial)",
|
||||
"总资产 − 总负债": "Total assets − Total liabilities",
|
||||
"账户资产 + 独立资产": "Account assets + Independent assets",
|
||||
"负债账户 + 独立债务": "Liability accounts + Independent debts",
|
||||
"净资产轨迹": "Net worth trend",
|
||||
"按业务日期": "By business date",
|
||||
"按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。": "Uses available exchange rates at each date. Historical corrections recalculate the trend. Archived items remain included.",
|
||||
"资产与负债组成": "Assets and liabilities",
|
||||
"个项目": " items",
|
||||
"从一个账户开始": "Start with an account",
|
||||
"添加银行、现金或投资账户,建立你的资产全貌。": "Add a bank, cash or investment account to see your finances.",
|
||||
"关联关系不会重复计入总额": "Links do not count balances twice",
|
||||
"最近变化": "Recent activity",
|
||||
"查看全部 →": "View all →",
|
||||
"· 汇率日期:": " · Rate dates:",
|
||||
"尚无外币汇率": "No foreign exchange rates yet",
|
||||
"· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。": " · Updates are attempted daily; failures preserve existing data. Older rates may affect conversions.",
|
||||
"变化归因": "Change attribution",
|
||||
"日期": "Date",
|
||||
"余额 / 估值变化": "Balance / valuation changes",
|
||||
"汇率变化": "Exchange rate changes",
|
||||
"汇率不完整": "Incomplete exchange rates",
|
||||
"返回列表": "Back to list",
|
||||
"资产": "Asset",
|
||||
"负债": "Liability",
|
||||
"· 已归档": " · Archived",
|
||||
"隐藏项目": "Hidden item",
|
||||
"本位币:": "Base currency:",
|
||||
"缺少汇率": "Missing exchange rate",
|
||||
"暂无备注": "No notes",
|
||||
"币种和资产负债属性创建后固定。归档项目仍计入总览。": "Currency and asset/liability type are fixed at creation. Archived items remain included in totals.",
|
||||
"编辑资料": "Edit details",
|
||||
"项目已恢复": "Item restored",
|
||||
"项目已归档": "Item archived",
|
||||
"恢复项目": "Restore item",
|
||||
"归档项目": "Archive item",
|
||||
"更新": "Update ",
|
||||
"估值": "valuation",
|
||||
"余额": "balance",
|
||||
"关联资产与账户": "Linked assets and accounts",
|
||||
"管理关联": "Manage links",
|
||||
"只展示关系,不改变资产或债务的统计。": "Links display relationships without changing asset or debt totals.",
|
||||
"尚未关联": "No links yet",
|
||||
"余额与估值历史": "Balance and valuation history",
|
||||
"每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。": "Each update preserves a separate record. Records on the same date are ordered by sequence. Correcting a record recalculates later changes.",
|
||||
"加载更早记录": "Load earlier records",
|
||||
"折合": "Equivalent to",
|
||||
"还没有": "No ",
|
||||
"添加真实数据后,这里将展示原币余额和历史变化。": "After adding your data, this page shows original balances and changes.",
|
||||
"账户转账": "Account transfers",
|
||||
"· 手续费": " · Fee ",
|
||||
"暂无账户间转账记录。": "No account transfers yet.",
|
||||
"加载更早转账": "Load earlier transfers",
|
||||
"全部金额变化": "All balance changes",
|
||||
"原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。": "Records store absolute balances. A decrease in liabilities indicates repayment. Select an item to view details and correct records.",
|
||||
"设置分类": "Settings category",
|
||||
"个人与菜单设置": "Personal & menu settings",
|
||||
"图标库": "Icon library",
|
||||
"每日汇率": "Daily exchange rates",
|
||||
"备份与恢复": "Backup & restore",
|
||||
"账号:": "Username:",
|
||||
"。切换本位币会重新换算整个历史,原币数据保留。": ". Changing the base currency recalculates history while preserving original values.",
|
||||
"个人设置已保存": "Personal settings saved",
|
||||
"选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。": "Choose which menus to display. Settings remain accessible. Hiding a menu does not delete data.",
|
||||
"显示": "Show ",
|
||||
"显示备注": "Show notes",
|
||||
"关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。": "Hide notes in lists, details and edit forms. Existing notes remain in backups.",
|
||||
"无操作自动退出(分钟)": "Sign out after inactivity (minutes)",
|
||||
"关闭自动退出": "Disable automatic sign-out",
|
||||
"分钟": " minutes",
|
||||
"0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。": "0 disables it; maximum 1440 minutes. Signing out clears the displayed data and keeps the browser tab open.",
|
||||
"保存设置": "Save settings",
|
||||
"服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可稍后重试。": "The server checks hourly whether today's update has been attempted. The public rate request sends no personal data. Failed updates preserve saved rates; you can retry later.",
|
||||
"汇率已更新至数据源最新可用日期": "Exchange rates updated to the latest available source date",
|
||||
"立即更新": "Update now",
|
||||
"公共参考汇率可能与银行成交价不同;休市日日期可能滞后。": "Reference rates may differ from bank rates. Non-trading days may use earlier dates.",
|
||||
"已保存汇率": "Saved exchange rates",
|
||||
"原币 → 本位币": "Original → Base currency",
|
||||
"汇率": "Rate",
|
||||
"来源": "Source",
|
||||
"历史导入": "Imported historical rate",
|
||||
"没有保存的外币汇率": "No saved foreign exchange rates",
|
||||
"添加外币项目后更新并保存自动汇率。": "Add an item in a foreign currency, then update and save exchange rates.",
|
||||
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "Clear all accounts, assets, debts, history, private icons and exchange rates, including hidden items. Your login, settings and published shared icons remain. Download and save a backup first.",
|
||||
"第一步:下载备份": "Step 1: Download backup",
|
||||
"备份尚未完成,请重新下载": "Backup is not ready; please download again",
|
||||
"备份已保存,进入下一步": "Backup saved; continue",
|
||||
"确定清空": "确定清空",
|
||||
"输入“确定清空”以确认": "Type “确定清空” to confirm",
|
||||
"取消清空 / 重新下载": "Cancel / download again",
|
||||
"数据备份与恢复": "Data backup & restore",
|
||||
"ZIP 内分文件保存可读 JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率、图标及配对转账,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。": "The ZIP contains separate readable JSON files for all accounts, assets, debts, history, links, currencies, settings, rates, icons and paired transfers, without record limits or login credentials. It contains personal financial information; store it securely.",
|
||||
"备份下载请求已发起,请检查浏览器下载列表": "Backup download requested; check your browser downloads",
|
||||
"下载我的数据备份": "Download my backup",
|
||||
"选择备份并验证": "Select and validate backup",
|
||||
"ZIP 文件不能超过 512 MB(不限制记录条数)": "ZIP files must not exceed 512 MB (no record count limit)",
|
||||
"ZIP 验证失败": "ZIP validation failed",
|
||||
"导入预览": "Import preview",
|
||||
"个项目 ·": " items ·",
|
||||
"条历史 ·": " history records ·",
|
||||
"条汇率": " exchange rates",
|
||||
"备份本位币": "Backup base currency",
|
||||
"/ 当前本位币": "/ Current base currency",
|
||||
"备份已完整导入": "Backup imported successfully",
|
||||
"确认追加导入": "Confirm append import",
|
||||
"取消": "Cancel",
|
||||
"WorthPath · 记录你的积累,理解你的财富": "WorthPath · Track your progress, understand your wealth",
|
||||
"第一次确认:清空本账号数据": "First confirmation: Clear this account's data",
|
||||
"第二次确认:清空本账号数据": "Second confirmation: Clear this account's data",
|
||||
"将清空本账号全部财务数据及私有图标,请确认备份已保存。": "All financial data and private icons in this account will be cleared. Confirm you have saved a backup.",
|
||||
"这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。": "This is the final confirmation. You can only restore from a backup afterward. Other users' data is unaffected.",
|
||||
"登录账号、个人设置和已发布的共享图标会保留。": "Your login account, personal settings and published shared icons remain.",
|
||||
"取消清空": "Cancel clearing",
|
||||
"继续清空": "Continue clearing",
|
||||
"本账号数据已清空": "Account data cleared",
|
||||
"正在清空…": "Clearing…",
|
||||
"最终确认清空": "Final confirmation: Clear data",
|
||||
"验证密码以显示隐藏资产": "Verify password to reveal hidden assets",
|
||||
"编辑项目": "Edit item",
|
||||
"更新余额 / 估值": "Update balance / valuation",
|
||||
"更正历史记录": "Correct history",
|
||||
"管理债务关联": "Manage debt links",
|
||||
"转账已保存,双方余额已更新": "Transfer saved; both balances updated",
|
||||
"隐藏项目已解锁 5 分钟": "Hidden items unlocked for 5 minutes",
|
||||
"关联已更新": "Links updated",
|
||||
"金额历史已保存,总览已重新计算": "Balance history saved; totals recalculated",
|
||||
"项目资料已保存": "Item details saved",
|
||||
"项目已添加": "Item added",
|
||||
"登录密码": "Login password",
|
||||
"隐藏此项目(密码验证后可查看和编辑)": "Hide this item (verify your password to view or edit)",
|
||||
"名称": "Name",
|
||||
"类别": "Category",
|
||||
"信用卡、贷款账户自动计入负债,其余账户计入资产。": "Credit cards and loan accounts count as liabilities; other accounts count as assets.",
|
||||
"原币币种": "Original currency",
|
||||
"变更后的绝对金额": "Absolute balance after this change",
|
||||
"业务时间(北京时间,精确到分钟)": "Business time (Beijing time, minute precision)",
|
||||
"变更原因": "Reason for change",
|
||||
"余额更新": "Balance update",
|
||||
"估值更新": "Valuation update",
|
||||
"还款(填写还款后欠款)": "Repayment (enter remaining debt)",
|
||||
"填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。": "Enter the updated balance or remaining debt. Each save adds a record. Correct existing mistakes in the history table.",
|
||||
"这将修改选中的历史金额并重算后续变化,请确认日期和余额。": "This changes the selected historical balance and recalculates later changes. Check the date and balance.",
|
||||
"请先创建账户或独立资产。": "Create an account or independent asset first.",
|
||||
"正在保存…": "Saving…",
|
||||
"确认更正": "Confirm correction",
|
||||
"保存": "Save",
|
||||
"项目 / 日期": "Item / Date",
|
||||
"变更前": "Before",
|
||||
"变更后": "After",
|
||||
"变化额": "Change",
|
||||
"操作": "Actions",
|
||||
"初始记录": "Initial record",
|
||||
"还款": "Repayment",
|
||||
"更正": "Correction",
|
||||
"转账转出": "Transfer out",
|
||||
"转账到账": "Transfer in",
|
||||
"暂无变化记录": "No activity yet",
|
||||
"添加项目或更新金额后,历史会自动保存在这里。": "History is saved here when you add an item or update a balance.",
|
||||
"请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。": "Create at least two active asset accounts first. Hidden accounts must be unlocked before selection.",
|
||||
"双方余额同时更新。正数手续费额外扣除,负数表示优惠抵扣(不超过转出金额);转账时间不能早于双方最新余额记录。": "Both balances update together. Positive fees add to the debit; negative fees are discounts up to the transfer amount. The time cannot precede either account's latest balance record.",
|
||||
"转出账户": "Source account",
|
||||
"转入账户": "Destination account",
|
||||
"转出金额(": "Amount sent (",
|
||||
"实际到账(": "Amount received (",
|
||||
"跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。": "For different currencies, enter the actual bank credit. This transfer does not change reference rates.",
|
||||
"手续费 / 优惠(": "Fee / discount (",
|
||||
",优惠填负数)": ", enter a negative discount)",
|
||||
"转账时间(北京时间)": "Transfer time (Beijing time)",
|
||||
"正在转账…": "Transferring…",
|
||||
"确认转账": "Confirm transfer",
|
||||
"已选账户图标": "Selected account icon",
|
||||
"更换图标": "Change icon",
|
||||
"选择图标": "Choose icon",
|
||||
"移除": "Remove",
|
||||
"选择账户图标": "Choose account icon",
|
||||
"关闭图标选择": "Close icon picker",
|
||||
"请选择图片": "Select an image",
|
||||
"请填写名称;共享图标须含中文并确认公开": "Enter a name; shared icons need a Chinese name and explicit public confirmation",
|
||||
"图片不能超过 2 MB": "Images must not exceed 2 MB",
|
||||
"上传失败": "Upload failed",
|
||||
"图标已发布,所有登录用户均可搜索和使用": "Icon published; all signed-in users can search and use it",
|
||||
"图标已存入我的图标,仅自己可见": "Icon saved to your private library",
|
||||
"账户图标库": "Account icon library",
|
||||
"搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。": "Search shared and private icons. Reuse a selected icon across accounts. Uploads are private by default.",
|
||||
"当前账户图标": "Current account icon",
|
||||
"已选择图标": "Selected icon",
|
||||
"移除图标": "Remove icon",
|
||||
"使用默认账户图标": "Use default account icon",
|
||||
"搜索图标名称": "Search icon names",
|
||||
"例如:工商银行、支付宝": "For example: ICBC, Alipay",
|
||||
"正在加载图标…": "Loading icons…",
|
||||
"共享": "Shared",
|
||||
"仅自己": "Private",
|
||||
"没有匹配图标,可以在下方上传。": "No matching icons. Upload one below.",
|
||||
"共": "Total ",
|
||||
"个图标": " icons",
|
||||
"上一页": "Previous",
|
||||
"下一页": "Next",
|
||||
"应用所选图标": "Apply selected icon",
|
||||
"上传我的图标 / 导入共享图标": "Upload private icon / Import shared icon",
|
||||
"图标名称(共享时必须含中文)": "Icon name (Chinese required when sharing)",
|
||||
"图片文件(PNG、JPG、WebP,最大 2 MB)": "Image file (PNG, JPG, WebP, up to 2 MB)",
|
||||
"发布到共享图标库": "Publish to shared library",
|
||||
"确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用": "I confirm this image can be public and reused by all users. Published icons remain available to others.",
|
||||
"正在上传…": "Uploading…",
|
||||
"导入共享图标": "Import shared icon",
|
||||
"上传私有图标": "Upload private icon",
|
||||
"服务响应异常": "Invalid server response",
|
||||
"语言": "Language",
|
||||
"借入到账": "Receive a loan",
|
||||
"借出付款": "Lend money",
|
||||
"收回借款": "Collect repayment",
|
||||
"偿还债务": "Repay debt",
|
||||
"借出(应收)": "Lent (receivable)",
|
||||
"借入(应付)": "Borrowed (payable)",
|
||||
"债务收款 / 还款": "Collect / repay debt",
|
||||
"账户与债务已同步更新": "Account and debt updated together",
|
||||
"请先创建启用的资产账户和借入或借出债务。": "Create an active asset account and a borrowed or lent debt first.",
|
||||
"收款账户": "Receiving account",
|
||||
"付款账户": "Paying account",
|
||||
"账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。": "The account and debt update together and are linked automatically. Enter the amount of this payment, not the resulting balance.",
|
||||
"本次账户金额": "Account payment amount",
|
||||
"本次债务本金": "Debt principal amount",
|
||||
"手续费 / 优惠": "Fee / discount",
|
||||
"手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。": "Fees affect only the account: added to outgoing payments or deducted from receipts. Negative fees represent discounts.",
|
||||
"债务方向": "Debt direction",
|
||||
"已有债务填写剩余本金;新借入或借出可先填0,保存后通过账户联动操作记录资金。": "For existing debt, enter the remaining principal. For a new loan, start at 0 and then record the money through a linked account.",
|
||||
"收款手续费不能超过收款金额": "The receipt fee cannot exceed the receipt amount.",
|
||||
"请选择有效的资产账户和对应借入或借出债务": "Choose an active asset account and the matching borrowed or lent debt.",
|
||||
"收款或还款不能超过剩余债务": "Collection or repayment cannot exceed the remaining debt.",
|
||||
"变更后的金额超出支持范围": "The resulting balance exceeds the supported range.",
|
||||
"手续费优惠不能超过转出金额": "The discount cannot exceed the payment amount.",
|
||||
"转出账户余额不足(含手续费)": "The source account has insufficient funds, including fees.",
|
||||
"转账时间不能早于任一账户的最新余额记录,请以当前余额转账": "The payment time cannot precede either item’s latest balance record.",
|
||||
"项目不存在": "Item not found.",
|
||||
"历史记录不存在": "History record not found.",
|
||||
"请先恢复归档项目": "Restore this archived item first.",
|
||||
"转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致": "Paired payments and their earlier history cannot be corrected separately. Add a balance adjustment to preserve both sides.",
|
||||
"余额调整时间不能早于已有转账;请使用当前时间调整": "Balance adjustments cannot precede an existing paired payment.",
|
||||
"账号或密码错误": "Incorrect username or password.",
|
||||
"未登录或会话已失效": "Not signed in, or your session has expired.",
|
||||
"服务暂时不可用,请稍后重试": "Service temporarily unavailable. Please try again.",
|
||||
"数据已存在或已被其他操作更新,请刷新后重试": "The data already exists or has changed. Refresh and retry.",
|
||||
"JSON 文件或请求格式无效": "Invalid JSON file or request format.",
|
||||
"转账请求标识已使用,请刷新后重试": "This payment request ID has been used. Refresh and retry.",
|
||||
"只能在自己的启用资产账户之间转账(隐藏账户须先解锁)": "Choose your own active items; unlock hidden items first.",
|
||||
"同币种转出与到账金额必须一致,手续费单独填写": "Amounts in the same currency must match. Enter the fee separately.",
|
||||
"日期范围超出限制": "The date range exceeds the limit.",
|
||||
"开始日期不能晚于结束日期": "The start date cannot follow the end date.",
|
||||
"请使用账户转账接口": "Use the paired payment form.",
|
||||
"手续费可为负数表示优惠,最多 16 位整数和 8 位小数": "The fee may be negative for a discount, with up to 16 integer digits and 8 decimals.",
|
||||
"尚未尝试更新": "No update attempted yet",
|
||||
"汇率更新正在进行": "Exchange rate update in progress",
|
||||
"正在更新汇率": "Updating exchange rates",
|
||||
"原币金额和已有汇率已保留": "Original balances and saved exchange rates have been preserved",
|
||||
"汇率更新失败,原币金额和已有汇率已保留,请稍后重试": "Exchange rate update failed. Original balances and saved rates are preserved. Please retry later.",
|
||||
"日期无效或在未来": "Invalid date or a future date",
|
||||
"业务时间无效或在未来(北京时间)": "Invalid or future business time (Beijing time)",
|
||||
"金额须为非负十进制字符串,最多 16 位整数和 8 位小数": "The amount must be a non-negative decimal with up to 16 integer digits and 8 decimals",
|
||||
"转出金额必须大于零": "The payment amount must be greater than zero",
|
||||
"到账金额必须大于零": "The receipt amount must be greater than zero",
|
||||
"不能向同一账户转账": "Choose two different items",
|
||||
"项目类型与资产负债属性不符": "The item type does not match its asset/liability side",
|
||||
"还款记录只能用于负债": "Repayment balance records are only available for liabilities",
|
||||
"还款后的欠款不能高于该业务日期的原欠款": "The remaining debt cannot exceed the previous debt at this business time",
|
||||
"请求来源不受信任": "Untrusted request origin",
|
||||
"会话已失效,请重新登录": "Your session expired. Please sign in again",
|
||||
"用户名已存在": "This username is already taken",
|
||||
"密码验证失败": "Password verification failed",
|
||||
"备份验证失败": "Backup validation failed",
|
||||
"转账关联无效": "Invalid paired payment links",
|
||||
"转账历史与双方金额不一致": "Paired payment history does not match its balances",
|
||||
"缺少配对转账记录": "Missing paired payment records",
|
||||
"需要先下载备份": "Download a backup first",
|
||||
"资金往来": "Money movements"
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"资产总览": "資產總覽",
|
||||
"账户": "賬戶",
|
||||
"独立资产": "獨立資產",
|
||||
"债务": "債務",
|
||||
"变化记录": "變化記錄",
|
||||
"设置与备份": "設置與備份",
|
||||
"银行账户": "銀行賬戶",
|
||||
"现金": "現金",
|
||||
"投资账户": "投資賬戶",
|
||||
"信用卡": "信用卡",
|
||||
"贷款账户": "貸款賬戶",
|
||||
"其他": "其他",
|
||||
"房产": "房產",
|
||||
"车辆": "車輛",
|
||||
"贵金属": "貴金屬",
|
||||
"其他资产": "其他資產",
|
||||
"房贷": "房貸",
|
||||
"车贷": "車貸",
|
||||
"个人借款": "個人借款",
|
||||
"其他债务": "其他債務",
|
||||
"备注": "備註",
|
||||
"清空本账号数据": "清空本賬號數據",
|
||||
"关闭": "關閉",
|
||||
"等待更多历史记录": "等待更多歷史記錄",
|
||||
"至少两个日期的完整余额和汇率记录,才能绘制趋势。": "至少兩個日期的完整餘額和匯率記錄,才能繪製趨勢。",
|
||||
"总资产": "總資產",
|
||||
"总负债": "總負債",
|
||||
"净资产": "淨資產",
|
||||
"资产、负债和净资产历史趋势": "資產、負債和淨資產歷史趨勢",
|
||||
"缺失汇率的日期未绘制,曲线在该处断开。": "缺失匯率的日期未繪製,曲線在該處斷開。",
|
||||
"操作失败": "操作失敗",
|
||||
"长时间无操作,已自动退出登录": "長時間無操作,已自動退出登錄",
|
||||
"新增": "新增",
|
||||
"WorthPath · 正在连接你的资产空间…": "WorthPath · 正在連接你的資產空間…",
|
||||
"让每一步积累,": "讓每一步積累,",
|
||||
"都有清晰的方向。": "都有清晰的方向。",
|
||||
"在一个空间里,连接你的账户、资产与债务。": "在一個空間裡,連接你的賬戶、資產與債務。",
|
||||
"看见当下,也看见净资产的每一次变化。": "看見當下,也看見淨資產的每一次變化。",
|
||||
"关注长期积累": "關注長期積累",
|
||||
"从第一份真实资产开始": "從第一份真實資產開始",
|
||||
"个人资产管理 · 多币种 · 历史追踪": "個人資產管理 · 多幣種 · 歷史追蹤",
|
||||
"开始你的资产之路": "開始你的資產之路",
|
||||
"创建账号": "創建賬號",
|
||||
"欢迎回来": "歡迎回來",
|
||||
"建立属于你的私人资产空间": "建立屬於你的私人資產空間",
|
||||
"登录以查看你的资产与负债": "登錄以查看你的資產與負債",
|
||||
"账号": "賬號",
|
||||
"输入账号(至少 3 个字符)": "輸入賬號(至少 3 個字符)",
|
||||
"密码": "密碼",
|
||||
"至少 10 个字符,最多 72 字节": "至少 10 個字符,最多 72 字節",
|
||||
"正在处理…": "正在處理…",
|
||||
"注册并进入": "註冊並進入",
|
||||
"登录": "登錄",
|
||||
"已有账号?": "已有賬號?",
|
||||
"还没有账号?": "還沒有賬號?",
|
||||
"个人数据按登录身份隔离": "個人數據按登錄身份隔離",
|
||||
"我的资产空间": "我的資產空間",
|
||||
"个人账户 ·": "個人賬戶 ·",
|
||||
"退出登录": "退出登錄",
|
||||
"个人财务 /": "個人財務 /",
|
||||
"本位币": "本位幣",
|
||||
"刷新数据": "刷新數據",
|
||||
"当前包含隐藏项目 · 验证 5 分钟后自动锁定": "當前包含隱藏項目 · 驗證 5 分鐘後自動鎖定",
|
||||
"当前不包含隐藏账户、资产和债务,净资产按此范围计算": "當前不包含隱藏賬戶、資產和債務,淨資產按此範圍計算",
|
||||
"隐藏项目已锁定": "隱藏項目已鎖定",
|
||||
"锁定隐藏项目": "鎖定隱藏項目",
|
||||
"显示隐藏资产(验证密码)": "顯示隱藏資產(驗證密碼)",
|
||||
"账户图标": "賬戶圖標",
|
||||
"你的财富全貌,从这里开始。": "你的財富全貌,從這裡開始。",
|
||||
"让数据始终可控,让换算清晰可靠。": "讓數據始終可控,讓換算清晰可靠。",
|
||||
"保留原币金额,追踪长期变化。": "保留原幣金額,追蹤長期變化。",
|
||||
"账户间转账": "賬戶間轉賬",
|
||||
"正在刷新数据…": "正在刷新數據…",
|
||||
"开始": "開始",
|
||||
"结束": "結束",
|
||||
"日(最多366天)": "日(最多366天)",
|
||||
"周(最多3年)": "周(最多3年)",
|
||||
"月(最多10年)": "月(最多10年)",
|
||||
"默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。": "默認最近90天;週一至週日及自然月取期末餘額,變化歸因累計區間內每日變化,首尾不足週期按實際範圍。",
|
||||
"缺少": "缺少",
|
||||
"到": "到",
|
||||
"的汇率。以下仅展示已能换算的部分,不能作为完整总额。": "的匯率。以下僅展示已能換算的部分,不能作為完整總額。",
|
||||
"设置汇率": "設置匯率",
|
||||
"(部分)": "(部分)",
|
||||
"总资产 − 总负债": "總資產 − 總負債",
|
||||
"账户资产 + 独立资产": "賬戶資產 + 獨立資產",
|
||||
"负债账户 + 独立债务": "負債賬戶 + 獨立債務",
|
||||
"净资产轨迹": "淨資產軌跡",
|
||||
"按业务日期": "按業務日期",
|
||||
"按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。": "按當時可用匯率重放;更正歷史後重新計算。歸檔項目仍計入。",
|
||||
"资产与负债组成": "資產與負債組成",
|
||||
"个项目": "個項目",
|
||||
"从一个账户开始": "從一個賬戶開始",
|
||||
"添加银行、现金或投资账户,建立你的资产全貌。": "添加銀行、現金或投資賬戶,建立你的資產全貌。",
|
||||
"关联关系不会重复计入总额": "關聯關係不會重複計入總額",
|
||||
"最近变化": "最近變化",
|
||||
"查看全部 →": "查看全部 →",
|
||||
"· 汇率日期:": "· 匯率日期:",
|
||||
"尚无外币汇率": "尚無外幣匯率",
|
||||
"· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。": "· 每日嘗試更新,失敗保留已有數據;舊日期匯率可能產生換算偏差。",
|
||||
"变化归因": "變化歸因",
|
||||
"日期": "日期",
|
||||
"余额 / 估值变化": "餘額 / 估值變化",
|
||||
"汇率变化": "匯率變化",
|
||||
"汇率不完整": "匯率不完整",
|
||||
"返回列表": "返回列表",
|
||||
"资产": "資產",
|
||||
"负债": "負債",
|
||||
"· 已归档": "· 已歸檔",
|
||||
"隐藏项目": "隱藏項目",
|
||||
"本位币:": "本位幣:",
|
||||
"缺少汇率": "缺少匯率",
|
||||
"暂无备注": "暫無備註",
|
||||
"币种和资产负债属性创建后固定。归档项目仍计入总览。": "幣種和資產負債屬性創建後固定。歸檔項目仍計入總覽。",
|
||||
"编辑资料": "編輯資料",
|
||||
"项目已恢复": "項目已恢復",
|
||||
"项目已归档": "項目已歸檔",
|
||||
"恢复项目": "恢復項目",
|
||||
"归档项目": "歸檔項目",
|
||||
"更新": "更新",
|
||||
"估值": "估值",
|
||||
"余额": "餘額",
|
||||
"关联资产与账户": "關聯資產與賬戶",
|
||||
"管理关联": "管理關聯",
|
||||
"只展示关系,不改变资产或债务的统计。": "只展示關係,不改變資產或債務的統計。",
|
||||
"尚未关联": "尚未關聯",
|
||||
"余额与估值历史": "餘額與估值歷史",
|
||||
"每次更新保留独立历史,同日按记录顺序展示。错误记录可单独更正,后续变化自动重算。": "每次更新保留獨立歷史,同日按記錄順序展示。錯誤記錄可單獨更正,後續變化自動重算。",
|
||||
"加载更早记录": "加載更早記錄",
|
||||
"折合": "摺合",
|
||||
"还没有": "還沒有",
|
||||
"添加真实数据后,这里将展示原币余额和历史变化。": "添加真實數據後,這裡將展示原幣餘額和歷史變化。",
|
||||
"账户转账": "賬戶轉賬",
|
||||
"· 手续费": "· 手續費",
|
||||
"暂无账户间转账记录。": "暫無賬戶間轉賬記錄。",
|
||||
"加载更早转账": "加載更早轉賬",
|
||||
"全部金额变化": "全部金額變化",
|
||||
"原币绝对余额记录;负债减少即还款。选择项目可查看详情并更正。": "原幣絕對餘額記錄;負債減少即還款。選擇項目可查看詳情並更正。",
|
||||
"设置分类": "設置分類",
|
||||
"个人与菜单设置": "個人與菜單設置",
|
||||
"图标库": "圖標庫",
|
||||
"每日汇率": "每日匯率",
|
||||
"备份与恢复": "備份與恢復",
|
||||
"账号:": "賬號:",
|
||||
"。切换本位币会重新换算整个历史,原币数据保留。": "。切換本位幣會重新換算整個歷史,原幣數據保留。",
|
||||
"个人设置已保存": "個人設置已保存",
|
||||
"选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。": "選擇需要顯示的菜單項。設置入口始終保留,隱藏僅影響導航,不刪除數據。",
|
||||
"显示": "顯示",
|
||||
"显示备注": "顯示備註",
|
||||
"关闭后隐藏列表、详情及编辑表单中的备注,已有备注仍会保留在备份中。": "關閉後隱藏列表、詳情及編輯表單中的備註,已有備註仍會保留在備份中。",
|
||||
"无操作自动退出(分钟)": "無操作自動退出(分鐘)",
|
||||
"关闭自动退出": "關閉自動退出",
|
||||
"分钟": "分鐘",
|
||||
"0 为关闭;最长 1440 分钟。到时退出登录并清除页面数据,浏览器标签页保留。": "0 為關閉;最長 1440 分鐘。到時退出登錄並清除頁面數據,瀏覽器標籤頁保留。",
|
||||
"保存设置": "保存設置",
|
||||
"服务器每小时检查当日是否已尝试更新。外部请求固定下载公共汇率表,不发送个人数据。失败保留已有汇率,可稍后重试。": "服務器每小時檢查當日是否已嘗試更新。外部請求固定下載公共匯率表,不發送個人數據。失敗保留已有匯率,可稍後重試。",
|
||||
"汇率已更新至数据源最新可用日期": "匯率已更新至數據源最新可用日期",
|
||||
"立即更新": "立即更新",
|
||||
"公共参考汇率可能与银行成交价不同;休市日日期可能滞后。": "公共參考匯率可能與銀行成交價不同;休市日日期可能滯後。",
|
||||
"已保存汇率": "已保存匯率",
|
||||
"原币 → 本位币": "原幣 → 本位幣",
|
||||
"汇率": "匯率",
|
||||
"来源": "來源",
|
||||
"历史导入": "歷史導入",
|
||||
"没有保存的外币汇率": "沒有保存的外幣匯率",
|
||||
"添加外币项目后更新并保存自动汇率。": "添加外幣項目後更新並保存自動匯率。",
|
||||
"清除本账号全部账户、资产、债务、历史、私有图标和汇率(包括隐藏项目);保留登录账号、个人设置和已发布的共享图标。请先下载备份并确认文件已保存。": "清除本賬號全部賬戶、資產、債務、歷史、私有圖標和匯率(包括隱藏項目);保留登錄賬號、個人設置和已發佈的共享圖標。請先下載備份並確認文件已保存。",
|
||||
"第一步:下载备份": "第一步:下載備份",
|
||||
"备份尚未完成,请重新下载": "備份尚未完成,請重新下載",
|
||||
"备份已保存,进入下一步": "備份已保存,進入下一步",
|
||||
"确定清空": "确定清空",
|
||||
"输入“确定清空”以确认": "輸入“確定清空”以確認",
|
||||
"取消清空 / 重新下载": "取消清空 / 重新下載",
|
||||
"数据备份与恢复": "數據備份與恢復",
|
||||
"ZIP 内分文件保存可读 JSON,包括全部账户、资产、债务、历史、关联、币种、设置、汇率、图标及配对转账,不限制记录条数;不包含任何认证凭据。备份含个人财务信息,请妥善保管。": "ZIP 內分文件保存可讀 JSON,包括全部賬戶、資產、債務、歷史、關聯、幣種、設置、匯率、圖標及配對轉賬,不限制記錄條數;不包含任何認證憑據。備份含個人財務信息,請妥善保管。",
|
||||
"备份下载请求已发起,请检查浏览器下载列表": "備份下載請求已發起,請檢查瀏覽器下載列表",
|
||||
"下载我的数据备份": "下載我的數據備份",
|
||||
"选择备份并验证": "選擇備份並驗證",
|
||||
"ZIP 文件不能超过 512 MB(不限制记录条数)": "ZIP 文件不能超過 512 MB(不限制記錄條數)",
|
||||
"ZIP 验证失败": "ZIP 驗證失敗",
|
||||
"导入预览": "導入預覽",
|
||||
"个项目 ·": "個項目 ·",
|
||||
"条历史 ·": "條歷史 ·",
|
||||
"条汇率": "條匯率",
|
||||
"备份本位币": "備份本位幣",
|
||||
"/ 当前本位币": "/ 當前本位幣",
|
||||
"备份已完整导入": "備份已完整導入",
|
||||
"确认追加导入": "確認追加導入",
|
||||
"取消": "取消",
|
||||
"WorthPath · 记录你的积累,理解你的财富": "WorthPath · 記錄你的積累,理解你的財富",
|
||||
"第一次确认:清空本账号数据": "第一次確認:清空本賬號數據",
|
||||
"第二次确认:清空本账号数据": "第二次確認:清空本賬號數據",
|
||||
"将清空本账号全部财务数据及私有图标,请确认备份已保存。": "將清空本賬號全部財務數據及私有圖標,請確認備份已保存。",
|
||||
"这是最后一次确认。清空后只能通过备份恢复,其他用户的数据不受影响。": "這是最後一次確認。清空後只能通過備份恢復,其他用戶的數據不受影響。",
|
||||
"登录账号、个人设置和已发布的共享图标会保留。": "登錄賬號、個人設置和已發佈的共享圖標會保留。",
|
||||
"取消清空": "取消清空",
|
||||
"继续清空": "繼續清空",
|
||||
"本账号数据已清空": "本賬號數據已清空",
|
||||
"正在清空…": "正在清空…",
|
||||
"最终确认清空": "最終確認清空",
|
||||
"验证密码以显示隐藏资产": "驗證密碼以顯示隱藏資產",
|
||||
"编辑项目": "編輯項目",
|
||||
"更新余额 / 估值": "更新余額 / 估值",
|
||||
"更正历史记录": "更正歷史記錄",
|
||||
"管理债务关联": "管理債務關聯",
|
||||
"转账已保存,双方余额已更新": "轉賬已保存,雙方餘額已更新",
|
||||
"隐藏项目已解锁 5 分钟": "隱藏項目已解鎖 5 分鐘",
|
||||
"关联已更新": "關聯已更新",
|
||||
"金额历史已保存,总览已重新计算": "金額歷史已保存,總覽已重新計算",
|
||||
"项目资料已保存": "項目資料已保存",
|
||||
"项目已添加": "項目已添加",
|
||||
"登录密码": "登錄密碼",
|
||||
"隐藏此项目(密码验证后可查看和编辑)": "隱藏此項目(密碼驗證後可查看和編輯)",
|
||||
"名称": "名稱",
|
||||
"类别": "類別",
|
||||
"信用卡、贷款账户自动计入负债,其余账户计入资产。": "信用卡、貸款賬戶自動計入負債,其餘賬戶計入資產。",
|
||||
"原币币种": "原幣幣種",
|
||||
"变更后的绝对金额": "變更後的絕對金額",
|
||||
"业务时间(北京时间,精确到分钟)": "業務時間(北京時間,精確到分鐘)",
|
||||
"变更原因": "變更原因",
|
||||
"余额更新": "餘額更新",
|
||||
"估值更新": "估值更新",
|
||||
"还款(填写还款后欠款)": "還款(填寫還款後欠款)",
|
||||
"填写更新后的余额或剩余欠款,每次保存会新增历史。已有错误记录请在历史中更正。": "填寫更新後的餘額或剩餘欠款,每次保存會新增歷史。已有錯誤記錄請在歷史中更正。",
|
||||
"这将修改选中的历史金额并重算后续变化,请确认日期和余额。": "這將修改選中的歷史金額並重算後續變化,請確認日期和餘額。",
|
||||
"请先创建账户或独立资产。": "請先創建賬戶或獨立資產。",
|
||||
"正在保存…": "正在保存…",
|
||||
"确认更正": "確認更正",
|
||||
"保存": "保存",
|
||||
"项目 / 日期": "項目 / 日期",
|
||||
"变更前": "變更前",
|
||||
"变更后": "變更後",
|
||||
"变化额": "變化額",
|
||||
"操作": "操作",
|
||||
"初始记录": "初始記錄",
|
||||
"还款": "還款",
|
||||
"更正": "更正",
|
||||
"转账转出": "轉賬轉出",
|
||||
"转账到账": "轉賬到賬",
|
||||
"暂无变化记录": "暫無變化記錄",
|
||||
"添加项目或更新金额后,历史会自动保存在这里。": "添加項目或更新金額後,歷史會自動保存在這裡。",
|
||||
"请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。": "請先創建至少兩個啟用中的資產賬戶。隱藏賬戶需驗證密碼後才能選擇。",
|
||||
"双方余额同时更新。正数手续费额外扣除,负数表示优惠抵扣(不超过转出金额);转账时间不能早于双方最新余额记录。": "雙方餘額同時更新。正數手續費額外扣除,負數表示優惠抵扣(不超過轉出金額);轉賬時間不能早於雙方最新余額記錄。",
|
||||
"转出账户": "轉出賬戶",
|
||||
"转入账户": "轉入賬戶",
|
||||
"转出金额(": "轉出金額(",
|
||||
"实际到账(": "實際到賬(",
|
||||
"跨币种请填写银行实际到账金额;本次转账不修改公共参考汇率。": "跨幣種請填寫銀行實際到賬金額;本次轉賬不修改公共參考匯率。",
|
||||
"手续费 / 优惠(": "手續費 / 優惠(",
|
||||
",优惠填负数)": ",優惠填負數)",
|
||||
"转账时间(北京时间)": "轉賬時間(北京時間)",
|
||||
"正在转账…": "正在轉賬…",
|
||||
"确认转账": "確認轉賬",
|
||||
"已选账户图标": "已選賬戶圖標",
|
||||
"更换图标": "更換圖標",
|
||||
"选择图标": "選擇圖標",
|
||||
"移除": "移除",
|
||||
"选择账户图标": "選擇賬戶圖標",
|
||||
"关闭图标选择": "關閉圖標選擇",
|
||||
"请选择图片": "請選擇圖片",
|
||||
"请填写名称;共享图标须含中文并确认公开": "請填寫名稱;共享圖標須含中文並確認公開",
|
||||
"图片不能超过 2 MB": "圖片不能超過 2 MB",
|
||||
"上传失败": "上傳失敗",
|
||||
"图标已发布,所有登录用户均可搜索和使用": "圖標已發佈,所有登錄用戶均可搜索和使用",
|
||||
"图标已存入我的图标,仅自己可见": "圖標已存入我的圖標,僅自己可見",
|
||||
"账户图标库": "賬戶圖標庫",
|
||||
"搜索共享图标和我的图标,选择后可在多个账户复用。直接上传默认仅自己可见。": "搜索共享圖標和我的圖標,選擇後可在多個賬戶複用。直接上傳默認僅自己可見。",
|
||||
"当前账户图标": "當前賬戶圖標",
|
||||
"已选择图标": "已選擇圖標",
|
||||
"移除图标": "移除圖標",
|
||||
"使用默认账户图标": "使用默認賬戶圖標",
|
||||
"搜索图标名称": "搜索圖標名稱",
|
||||
"例如:工商银行、支付宝": "例如:工商銀行、支付寶",
|
||||
"正在加载图标…": "正在加載圖標…",
|
||||
"共享": "共享",
|
||||
"仅自己": "僅自己",
|
||||
"没有匹配图标,可以在下方上传。": "沒有匹配圖標,可以在下方上傳。",
|
||||
"共": "共",
|
||||
"个图标": "個圖標",
|
||||
"上一页": "上一頁",
|
||||
"下一页": "下一頁",
|
||||
"应用所选图标": "應用所選圖標",
|
||||
"上传我的图标 / 导入共享图标": "上傳我的圖標 / 導入共享圖標",
|
||||
"图标名称(共享时必须含中文)": "圖標名稱(共享時必須含中文)",
|
||||
"图片文件(PNG、JPG、WebP,最大 2 MB)": "圖片文件(PNG、JPG、WebP,最大 2 MB)",
|
||||
"发布到共享图标库": "發佈到共享圖標庫",
|
||||
"确认此图片可公开,所有用户均可搜索并复用;已发布图标会保留供他人使用": "確認此圖片可公開,所有用戶均可搜索並複用;已發佈圖標會保留供他人使用",
|
||||
"正在上传…": "正在上傳…",
|
||||
"导入共享图标": "導入共享圖標",
|
||||
"上传私有图标": "上傳私有圖標",
|
||||
"服务响应异常": "服務響應異常",
|
||||
"语言": "語言",
|
||||
"借入到账": "借入到賬",
|
||||
"借出付款": "借出付款",
|
||||
"收回借款": "收回借款",
|
||||
"偿还债务": "償還債務",
|
||||
"借出(应收)": "借出(應收)",
|
||||
"借入(应付)": "借入(應付)",
|
||||
"债务收款 / 还款": "債務收款 / 還款",
|
||||
"账户与债务已同步更新": "賬戶與債務已同步更新",
|
||||
"请先创建启用的资产账户和借入或借出债务。": "請先創建啟用的資產賬戶和借入或借出債務。",
|
||||
"收款账户": "收款賬戶",
|
||||
"付款账户": "付款賬戶",
|
||||
"账户与债务同时记账并自动建立关联;这里填写本次金额,不是操作后的余额。": "賬戶與債務同時記賬並自動建立關聯;這裡填寫本次金額,不是操作後的餘額。",
|
||||
"本次账户金额": "本次賬戶金額",
|
||||
"本次债务本金": "本次債務本金",
|
||||
"手续费 / 优惠": "手續費 / 優惠",
|
||||
"手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。": "手續費隻影響賬戶餘額:付款時額外扣除,收款時從到賬金額扣除;負數表示優惠。",
|
||||
"债务方向": "債務方向",
|
||||
"已有债务填写剩余本金;新借入或借出可先填0,保存后通过账户联动操作记录资金。": "已有債務填寫剩餘本金;新借入或借出可先填0,保存後通過賬戶聯動操作記錄資金。",
|
||||
"收款手续费不能超过收款金额": "收款手續費不能超過收款金額",
|
||||
"请选择有效的资产账户和对应借入或借出债务": "請選擇有效的資產賬戶和對應借入或借出債務",
|
||||
"收款或还款不能超过剩余债务": "收款或還款不能超過剩餘債務",
|
||||
"变更后的金额超出支持范围": "變更後的金額超出支持範圍",
|
||||
"手续费优惠不能超过转出金额": "手續費優惠不能超過轉出金額",
|
||||
"转出账户余额不足(含手续费)": "轉出賬戶餘額不足(含手續費)",
|
||||
"转账时间不能早于任一账户的最新余额记录,请以当前余额转账": "轉賬時間不能早於任一賬戶的最新余額記錄,請以當前餘額轉賬",
|
||||
"项目不存在": "項目不存在",
|
||||
"历史记录不存在": "歷史記錄不存在",
|
||||
"请先恢复归档项目": "請先恢復歸檔項目",
|
||||
"转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致": "轉賬及其之前的歷史不可單獨更正;請新增餘額調整,保留轉賬雙方一致",
|
||||
"余额调整时间不能早于已有转账;请使用当前时间调整": "餘額調整時間不能早於已有轉賬;請使用當前時間調整",
|
||||
"账号或密码错误": "賬號或密碼錯誤",
|
||||
"未登录或会话已失效": "未登錄或會話已失效",
|
||||
"服务暂时不可用,请稍后重试": "服務暫時不可用,請稍後重試",
|
||||
"数据已存在或已被其他操作更新,请刷新后重试": "數據已存在或已被其他操作更新,請刷新後重試",
|
||||
"JSON 文件或请求格式无效": "JSON 文件或請求格式無效",
|
||||
"转账请求标识已使用,请刷新后重试": "轉賬請求標識已使用,請刷新後重試",
|
||||
"只能在自己的启用资产账户之间转账(隐藏账户须先解锁)": "只能在自己的啟用資產賬戶之間轉賬(隱藏賬戶須先解鎖)",
|
||||
"同币种转出与到账金额必须一致,手续费单独填写": "同幣種轉出與到賬金額必須一致,手續費單獨填寫",
|
||||
"日期范围超出限制": "日期範圍超出限制",
|
||||
"开始日期不能晚于结束日期": "開始日期不能晚於結束日期",
|
||||
"请使用账户转账接口": "請使用賬戶轉賬接口",
|
||||
"手续费可为负数表示优惠,最多 16 位整数和 8 位小数": "手續費可為負數表示優惠,最多 16 位整數和 8 位小數",
|
||||
"尚未尝试更新": "尚未嘗試更新",
|
||||
"汇率更新正在进行": "匯率更新正在進行",
|
||||
"正在更新汇率": "正在更新匯率",
|
||||
"原币金额和已有汇率已保留": "原幣金額和已有匯率已保留",
|
||||
"汇率更新失败,原币金额和已有汇率已保留,请稍后重试": "匯率更新失敗,原幣金額和已有匯率已保留,請稍後重試",
|
||||
"日期无效或在未来": "日期無效或在未來",
|
||||
"业务时间无效或在未来(北京时间)": "業務時間無效或在未來(北京時間)",
|
||||
"金额须为非负十进制字符串,最多 16 位整数和 8 位小数": "金額須為非負十進制字符串,最多 16 位整數和 8 位小數",
|
||||
"转出金额必须大于零": "轉出金額必須大於零",
|
||||
"到账金额必须大于零": "到賬金額必須大於零",
|
||||
"不能向同一账户转账": "不能向同一賬戶轉賬",
|
||||
"项目类型与资产负债属性不符": "項目類型與資產負債屬性不符",
|
||||
"还款记录只能用于负债": "還款記錄只能用於負債",
|
||||
"还款后的欠款不能高于该业务日期的原欠款": "還款後的欠款不能高於該業務日期的原欠款",
|
||||
"请求来源不受信任": "請求來源不受信任",
|
||||
"会话已失效,请重新登录": "會話已失效,請重新登錄",
|
||||
"用户名已存在": "用戶名已存在",
|
||||
"密码验证失败": "密碼驗證失敗",
|
||||
"备份验证失败": "備份驗證失敗",
|
||||
"转账关联无效": "轉賬關聯無效",
|
||||
"转账历史与双方金额不一致": "轉賬歷史與雙方金額不一致",
|
||||
"缺少配对转账记录": "缺少配對轉賬記錄",
|
||||
"需要先下载备份": "需要先下載備份",
|
||||
"资金往来": "資金往來"
|
||||
}
|
||||
@@ -1177,3 +1177,29 @@ footer {
|
||||
.notes-hidden label:has(.note-input) {
|
||||
display: none !important;
|
||||
}
|
||||
.language-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.language-picker select {
|
||||
width: auto;
|
||||
min-width: 7rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
}
|
||||
.auth-language {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.language-picker span {
|
||||
display: none;
|
||||
}
|
||||
.topbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { translate, validLocale } from '../src/i18n';
|
||||
import english from '../src/locales/en.json';
|
||||
import traditional from '../src/locales/zh-Hant.json';
|
||||
test('locale dictionaries match and preserve monetary values and the required confirmation phrase', () => {
|
||||
assert.deepEqual(Object.keys(english), Object.keys(traditional));
|
||||
assert.equal(translate('账户', 'en'), 'Accounts');
|
||||
assert.equal(translate('账户', 'zh-Hant'), '賬戶');
|
||||
assert.equal(translate('Accounts', 'zh-Hans'), '账户');
|
||||
assert.equal(translate('借入到账', 'en'), 'Receive a loan');
|
||||
for (const locale of ['en', 'zh-Hans', 'zh-Hant'] as const) {
|
||||
assert.equal(translate('确定清空', locale), '确定清空');
|
||||
assert.equal(translate('9999999999999999.98765432', locale), '9999999999999999.98765432');
|
||||
}
|
||||
assert.equal(
|
||||
translate('fee: 手续费优惠不能超过转出金额', 'en'),
|
||||
'fee: The discount cannot exceed the payment amount.',
|
||||
);
|
||||
assert.equal(validLocale('en'), true);
|
||||
assert.equal(validLocale('fr'), false);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# WorthPath 首版设计
|
||||
|
||||
2026-10-02 更新:查询、历史分页、趋势重放、借入借出联动及三语言的当前实现和实测见 [性能与联动验收](performance.md)。以下首版设计中的历史 ZIP v3/v4 说明保留作格式沿革;当前导出为 v6,兼容读取 v3/v4/v5/v6 ZIP 和旧 JSON。
|
||||
|
||||
NestJS API、React/Vite 前端和 MySQL;同源 /api,开发环境由 Vite 代理。
|
||||
|
||||
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。每个金额均为非负原币余额,负债按 side 减去。
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"tableCount": 8,
|
||||
"columnCount": 73,
|
||||
"tables": [
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"TABLE_COMMENT": "用户原币兑本位币的历史汇率"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"TABLE_COMMENT": "可复用账户图标"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"TABLE_COMMENT": "持有项目:账户、独立资产和借入借出债务"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"TABLE_COMMENT": "项目关联关系,不参与金额求和"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"TABLE_COMMENT": "余额历史:金额为变更后的绝对余额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"TABLE_COMMENT": "登录会话与隐藏项目查看授权"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"TABLE_COMMENT": "资金往来及账户与债务的双边配对记账"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"TABLE_COMMENT": "用户及个人设置"
|
||||
}
|
||||
],
|
||||
"columns": [
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "userId",
|
||||
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "currency",
|
||||
"COLUMN_COMMENT": "原始币种代码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "baseCurrency",
|
||||
"COLUMN_COMMENT": "本位币代码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "date",
|
||||
"COLUMN_COMMENT": "汇率业务日期"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "rate",
|
||||
"COLUMN_COMMENT": "每单位原币对应的本位币金额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "exchangerate",
|
||||
"COLUMN_NAME": "source",
|
||||
"COLUMN_COMMENT": "汇率来源标识,保留已有历史来源"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "ownerId",
|
||||
"COLUMN_COMMENT": "上传用户标识;空可表示系统图标或原用户已删除"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "name",
|
||||
"COLUMN_COMMENT": "显示名称"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "shared",
|
||||
"COLUMN_COMMENT": "是否公开共享给已登录用户"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "hash",
|
||||
"COLUMN_COMMENT": "规范化图标内容的 SHA-256 摘要"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "data",
|
||||
"COLUMN_COMMENT": "规范化静态 PNG 图片二进制"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "source",
|
||||
"COLUMN_COMMENT": "可选公开来源网址"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "userId",
|
||||
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "kind",
|
||||
"COLUMN_COMMENT": "项目类型:account 账户、asset 独立资产、debt 债务"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "side",
|
||||
"COLUMN_COMMENT": "统计方向:asset 资产或借出应收、liability 负债或借入应付"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "name",
|
||||
"COLUMN_COMMENT": "显示名称"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "category",
|
||||
"COLUMN_COMMENT": "项目分类代码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "currency",
|
||||
"COLUMN_COMMENT": "原始币种代码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "notes",
|
||||
"COLUMN_COMMENT": "用户备注,原文保留"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "archived",
|
||||
"COLUMN_COMMENT": "是否归档;归档项目仍计入总额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "updatedAt",
|
||||
"COLUMN_COMMENT": "记录更新时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "importedFromId",
|
||||
"COLUMN_COMMENT": "备份导入来源项目标识,用于重复导入识别"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "hidden",
|
||||
"COLUMN_COMMENT": "是否隐藏;未解锁时不参与可见范围统计"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "iconId",
|
||||
"COLUMN_COMMENT": "关联账户图标标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"COLUMN_NAME": "sourceId",
|
||||
"COLUMN_COMMENT": "关联来源项目标识,通常为债务"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"COLUMN_NAME": "targetId",
|
||||
"COLUMN_COMMENT": "关联目标项目标识,通常为账户或资产"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "positionId",
|
||||
"COLUMN_COMMENT": "所属持有项目标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "amount",
|
||||
"COLUMN_COMMENT": "变更后的原币绝对余额,不是收支增量,禁止对全部历史求和"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "effectiveDate",
|
||||
"COLUMN_COMMENT": "北京时间业务时间对应的 UTC,精确到分钟"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "notes",
|
||||
"COLUMN_COMMENT": "用户备注,原文保留"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "reason",
|
||||
"COLUMN_COMMENT": "变更原因:初始、余额、估值、转账或借贷配对操作"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "updatedAt",
|
||||
"COLUMN_COMMENT": "记录更新时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "revision",
|
||||
"COLUMN_NAME": "sequence",
|
||||
"COLUMN_COMMENT": "数据库自增唯一顺序,同业务时间按此排序"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "随机会话令牌的 SHA-256 摘要"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "userId",
|
||||
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "expiresAt",
|
||||
"COLUMN_COMMENT": "会话过期时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "lastActivity",
|
||||
"COLUMN_COMMENT": "最后有效交互时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "revealUntil",
|
||||
"COLUMN_COMMENT": "隐藏项目授权截止时间,UTC;空表示未解锁"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "backupDigest",
|
||||
"COLUMN_COMMENT": "已下载备份内容的 SHA-256 摘要"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "session",
|
||||
"COLUMN_NAME": "backupExpiresAt",
|
||||
"COLUMN_COMMENT": "备份清空确认凭据截止时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "importedFromId",
|
||||
"COLUMN_COMMENT": "备份导入来源项目标识,用于重复导入识别"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "userId",
|
||||
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "sourceId",
|
||||
"COLUMN_COMMENT": "来源资产账户标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "targetId",
|
||||
"COLUMN_COMMENT": "目标账户或债务标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "sourceRevisionId",
|
||||
"COLUMN_COMMENT": "来源账户配对余额历史标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "targetRevisionId",
|
||||
"COLUMN_COMMENT": "目标项目配对余额历史标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "sourceCurrency",
|
||||
"COLUMN_COMMENT": "来源账户币种"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "targetCurrency",
|
||||
"COLUMN_COMMENT": "目标项目币种"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "amount",
|
||||
"COLUMN_COMMENT": "本次来源账户本金金额,不是变更后余额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "received",
|
||||
"COLUMN_COMMENT": "本次目标到账金额或债务本金金额"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "fee",
|
||||
"COLUMN_COMMENT": "来源币种手续费;负数表示优惠"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "effectiveDate",
|
||||
"COLUMN_COMMENT": "北京时间业务时间对应的 UTC,精确到分钟"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "notes",
|
||||
"COLUMN_COMMENT": "用户备注,原文保留"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "transfer",
|
||||
"COLUMN_NAME": "operation",
|
||||
"COLUMN_COMMENT": "操作类型:transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "id",
|
||||
"COLUMN_COMMENT": "唯一标识"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "username",
|
||||
"COLUMN_COMMENT": "登录用户名,唯一"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "passwordHash",
|
||||
"COLUMN_COMMENT": "登录密码的 bcrypt 哈希,不存储明文"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "baseCurrency",
|
||||
"COLUMN_COMMENT": "本位币代码"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "updatedAt",
|
||||
"COLUMN_COMMENT": "记录更新时间,UTC"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "idleMinutes",
|
||||
"COLUMN_COMMENT": "无操作退出分钟数,0 表示关闭"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "hiddenMenus",
|
||||
"COLUMN_COMMENT": "隐藏菜单标识列表"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "showNotes",
|
||||
"COLUMN_COMMENT": "是否显示备注"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"phase": "final",
|
||||
"node": "v24.21.0",
|
||||
"results": [
|
||||
{
|
||||
"accounts": 1,
|
||||
"revisionsPerAccount": 10000,
|
||||
"baseline": [
|
||||
{
|
||||
"name": "old positions",
|
||||
"medianMs": 182.84,
|
||||
"bytes": 6097025,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "old overview",
|
||||
"medianMs": 949.27,
|
||||
"bytes": 38903,
|
||||
"queries": 4
|
||||
}
|
||||
],
|
||||
"optimized": [
|
||||
{
|
||||
"name": "new positions",
|
||||
"medianMs": 2.67,
|
||||
"bytes": 368,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new current overview",
|
||||
"medianMs": 2.42,
|
||||
"bytes": 316,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new history page",
|
||||
"medianMs": 21.32,
|
||||
"bytes": 16462,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new trend",
|
||||
"medianMs": 51.91,
|
||||
"bytes": 13485,
|
||||
"queries": 7
|
||||
}
|
||||
],
|
||||
"httpResults": [
|
||||
{
|
||||
"name": "HTTP positions",
|
||||
"medianMs": 11.59,
|
||||
"bytes": 368,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP overview",
|
||||
"medianMs": 7.36,
|
||||
"bytes": 354,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP history",
|
||||
"medianMs": 19.2,
|
||||
"bytes": 16462,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP trend",
|
||||
"medianMs": 54.64,
|
||||
"bytes": 13585,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"baselineHttp": [
|
||||
{
|
||||
"name": "old HTTP positions",
|
||||
"medianMs": 288.13,
|
||||
"bytes": 6097025,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "old HTTP overview",
|
||||
"medianMs": 992.35,
|
||||
"bytes": 38920,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"nestedSql": [
|
||||
"SELECT `worthpath`.`Position`.`id`, `worthpath`.`Position`.`importedFromId`, `worthpath`.`Position`.`userId`, `worthpath`.`Position`.`kind`, `worthpath`.`Position`.`side`, `worthpath`.`Position`.`name`, `worthpath`.`Position`.`category`, `worthpath`.`Position`.`currency`, `worthpath`.`Position`.`notes`, `worthpath`.`Position`.`archived`, `worthpath`.`Position`.`hidden`, `worthpath`.`Position`.`createdAt`, `worthpath`.`Position`.`updatedAt`, `worthpath`.`Position`.`iconId` FROM `worthpath`.`Position` WHERE `worthpath`.`Position`.`userId` = ?",
|
||||
"SELECT `worthpath`.`Revision`.`id`, `worthpath`.`Revision`.`sequence`, `worthpath`.`Revision`.`positionId`, `worthpath`.`Revision`.`amount`, `worthpath`.`Revision`.`effectiveDate`, `worthpath`.`Revision`.`notes`, `worthpath`.`Revision`.`reason`, `worthpath`.`Revision`.`createdAt`, `worthpath`.`Revision`.`updatedAt` FROM `worthpath`.`Revision` WHERE `worthpath`.`Revision`.`positionId` IN (?) ORDER BY `worthpath`.`Revision`.`effectiveDate` DESC, `worthpath`.`Revision`.`sequence` DESC LIMIT ? OFFSET ?"
|
||||
],
|
||||
"latestPlan": [
|
||||
{
|
||||
"f0": "-> Limit: 1 row(s) (cost=0.71 rows=1) (actual time=0.0158..0.0158 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=0.71 rows=1) (actual time=0.0153..0.0153 rows=1 loops=1)\n"
|
||||
}
|
||||
],
|
||||
"pagePlan": [
|
||||
{
|
||||
"f0": "-> Append (cost=0..0 rows=51) (actual time=0.519..6.4 rows=51 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.519..0.519 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..70e-6 rows=1 loops=1)\n -> Select #2 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=2252 rows=1) (actual time=0.514..0.514 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 340720) (reverse), with index condition: ((x.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 340720)))) (cost=2252 rows=5004) (actual time=0.513..0.513 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.148..0.148 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #4 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=2302 rows=1) (actual time=0.145..0.145 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 340719) (reverse), with index condition: ((x.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 340719)))) (cost=2302 rows=5114) (actual time=0.145..0.145 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.141..0.141 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #6 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=2301 rows=1) (actual time=0.139..0.139 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 340718) (reverse), with index condition: ((x.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 340718)))) (cost=2301 rows=5113) (actual time=0.139..0.139 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.146..0.146 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #8 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=2301 rows=1) (actual time=0.144..0.144 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 340717) (reverse), with index condition: ((x.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 340717)))) (cost=2301 rows=5112) (actual time=0.144..0.144 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.136..0.136 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=60e-6..80e-6 rows=1 loops=1)\n -> Select #10 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=2300 rows=1) (actual time=0.134..0.134 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = '48c0ee00-069d-4443-9274-1d454c986c33' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 340716) (reverse), with index condition: ((x.positionId = '48c0ee00-069d-4443-9274-1d454c986c33') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 340716)))) (cost=2300 rows=5111) (actual time=0.134..0.134 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.134..0.134 rows=1 loops=1)Line truncated
|
||||
}
|
||||
],
|
||||
"candidatePlans": [
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=501 rows=51) (actual time=0.0183..0.111 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='48c0ee00-069d-4443-9274-1d454c986c33') (reverse) (cost=501 rows=5003) (actual time=0.0179..0.108 rows=51 loops=1)\n"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"accounts": 1,
|
||||
"revisionsPerAccount": 100000,
|
||||
"baseline": [
|
||||
{
|
||||
"name": "old positions",
|
||||
"medianMs": 2240.74,
|
||||
"bytes": 61267028,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "old overview",
|
||||
"medianMs": 10619.53,
|
||||
"bytes": 39395,
|
||||
"queries": 4
|
||||
}
|
||||
],
|
||||
"optimized": [
|
||||
{
|
||||
"name": "new positions",
|
||||
"medianMs": 2.74,
|
||||
"bytes": 370,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new current overview",
|
||||
"medianMs": 2.13,
|
||||
"bytes": 320,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new history page",
|
||||
"medianMs": 33.3,
|
||||
"bytes": 16562,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new trend",
|
||||
"medianMs": 587.94,
|
||||
"bytes": 13754,
|
||||
"queries": 7
|
||||
}
|
||||
],
|
||||
"httpResults": [
|
||||
{
|
||||
"name": "HTTP positions",
|
||||
"medianMs": 5.56,
|
||||
"bytes": 370,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP overview",
|
||||
"medianMs": 5.39,
|
||||
"bytes": 358,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP history",
|
||||
"medianMs": 38.5,
|
||||
"bytes": 16562,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP trend",
|
||||
"medianMs": 585.14,
|
||||
"bytes": 13854,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"baselineHttp": [
|
||||
{
|
||||
"name": "old HTTP positions",
|
||||
"medianMs": 2826.9,
|
||||
"bytes": 61267028,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "old HTTP overview",
|
||||
"medianMs": 9885.51,
|
||||
"bytes": 39412,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"nestedSql": [
|
||||
"SELECT `worthpath`.`Position`.`id`, `worthpath`.`Position`.`importedFromId`, `worthpath`.`Position`.`userId`, `worthpath`.`Position`.`kind`, `worthpath`.`Position`.`side`, `worthpath`.`Position`.`name`, `worthpath`.`Position`.`category`, `worthpath`.`Position`.`currency`, `worthpath`.`Position`.`notes`, `worthpath`.`Position`.`archived`, `worthpath`.`Position`.`hidden`, `worthpath`.`Position`.`createdAt`, `worthpath`.`Position`.`updatedAt`, `worthpath`.`Position`.`iconId` FROM `worthpath`.`Position` WHERE `worthpath`.`Position`.`userId` = ?",
|
||||
"SELECT `worthpath`.`Revision`.`id`, `worthpath`.`Revision`.`sequence`, `worthpath`.`Revision`.`positionId`, `worthpath`.`Revision`.`amount`, `worthpath`.`Revision`.`effectiveDate`, `worthpath`.`Revision`.`notes`, `worthpath`.`Revision`.`reason`, `worthpath`.`Revision`.`createdAt`, `worthpath`.`Revision`.`updatedAt` FROM `worthpath`.`Revision` WHERE `worthpath`.`Revision`.`positionId` IN (?) ORDER BY `worthpath`.`Revision`.`effectiveDate` DESC, `worthpath`.`Revision`.`sequence` DESC LIMIT ? OFFSET ?"
|
||||
],
|
||||
"latestPlan": [
|
||||
{
|
||||
"f0": "-> Limit: 1 row(s) (cost=0.71 rows=1) (actual time=0.0203..0.0203 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=0.71 rows=1) (actual time=0.0198..0.0198 rows=1 loops=1)\n"
|
||||
}
|
||||
],
|
||||
"pagePlan": [
|
||||
{
|
||||
"f0": "-> Append (cost=0..0 rows=51) (actual time=0.634..35.5 rows=51 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.634..0.634 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..70e-6 rows=1 loops=1)\n -> Select #2 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=22711 rows=1) (actual time=0.626..0.626 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 440721) (reverse), with index condition: ((x.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 440721)))) (cost=22711 rows=50467) (actual time=0.625..0.625 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.511..0.511 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #4 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=23215 rows=1) (actual time=0.506..0.506 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 440720) (reverse), with index condition: ((x.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 440720)))) (cost=23215 rows=51588) (actual time=0.505..0.505 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.497..0.497 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #6 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=23215 rows=1) (actual time=0.493..0.493 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 440719) (reverse), with index condition: ((x.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 440719)))) (cost=23215 rows=51587) (actual time=0.493..0.493 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.436..0.436 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=50e-6..70e-6 rows=1 loops=1)\n -> Select #8 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=23214 rows=1) (actual time=0.432..0.432 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 440718) (reverse), with index condition: ((x.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 440718)))) (cost=23214 rows=51586) (actual time=0.432..0.432 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.424..0.424 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #10 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=23214 rows=1) (actual time=0.421..0.421 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate < '2026-09-30 16:00:00.000') OR (positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de' AND effectiveDate = '2026-09-30 16:00:00.000' AND sequence < 440717) (reverse), with index condition: ((x.positionId = 'd4a20795-cbba-4260-8958-1a19c3fd49de') and ((x.effectiveDate < TIMESTAMP'2026-09-30 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-09-30 16:00:00') and (x.sequence < 440717)))) (cost=23214 rows=51585) (actual time=0.421..0.421 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.492..0.492Line truncated
|
||||
}
|
||||
],
|
||||
"candidatePlans": [
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=6160 rows=51) (actual time=0.0321..0.211 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='d4a20795-cbba-4260-8958-1a19c3fd49de') (reverse) (cost=6160 rows=50466) (actual time=0.0313..0.208 rows=51 loops=1)\n"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"accounts": 10,
|
||||
"revisionsPerAccount": 10000,
|
||||
"baseline": [
|
||||
{
|
||||
"name": "old positions",
|
||||
"medianMs": 2091.34,
|
||||
"bytes": 60970241,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "old overview",
|
||||
"medianMs": 16928.24,
|
||||
"bytes": 199599,
|
||||
"queries": 5
|
||||
}
|
||||
],
|
||||
"optimized": [
|
||||
{
|
||||
"name": "new positions",
|
||||
"medianMs": 4.17,
|
||||
"bytes": 3671,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new current overview",
|
||||
"medianMs": 2.98,
|
||||
"bytes": 2100,
|
||||
"queries": 5
|
||||
},
|
||||
{
|
||||
"name": "new history page",
|
||||
"medianMs": 39.58,
|
||||
"bytes": 16471,
|
||||
"queries": 14
|
||||
},
|
||||
{
|
||||
"name": "new trend",
|
||||
"medianMs": 582.64,
|
||||
"bytes": 13755,
|
||||
"queries": 7
|
||||
}
|
||||
],
|
||||
"httpResults": [
|
||||
{
|
||||
"name": "HTTP positions",
|
||||
"medianMs": 6.19,
|
||||
"bytes": 3671,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP overview",
|
||||
"medianMs": 5.48,
|
||||
"bytes": 2138,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP history",
|
||||
"medianMs": 34.32,
|
||||
"bytes": 16471,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "HTTP trend",
|
||||
"medianMs": 568.12,
|
||||
"bytes": 13855,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"baselineHttp": [
|
||||
{
|
||||
"name": "old HTTP positions",
|
||||
"medianMs": 2869.42,
|
||||
"bytes": 60970241,
|
||||
"queries": 0
|
||||
},
|
||||
{
|
||||
"name": "old HTTP overview",
|
||||
"medianMs": 15636.96,
|
||||
"bytes": 199616,
|
||||
"queries": 0
|
||||
}
|
||||
],
|
||||
"nestedSql": [
|
||||
"SELECT `worthpath`.`Position`.`id`, `worthpath`.`Position`.`importedFromId`, `worthpath`.`Position`.`userId`, `worthpath`.`Position`.`kind`, `worthpath`.`Position`.`side`, `worthpath`.`Position`.`name`, `worthpath`.`Position`.`category`, `worthpath`.`Position`.`currency`, `worthpath`.`Position`.`notes`, `worthpath`.`Position`.`archived`, `worthpath`.`Position`.`hidden`, `worthpath`.`Position`.`createdAt`, `worthpath`.`Position`.`updatedAt`, `worthpath`.`Position`.`iconId` FROM `worthpath`.`Position` WHERE `worthpath`.`Position`.`userId` = ?",
|
||||
"SELECT `worthpath`.`Revision`.`id`, `worthpath`.`Revision`.`sequence`, `worthpath`.`Revision`.`positionId`, `worthpath`.`Revision`.`amount`, `worthpath`.`Revision`.`effectiveDate`, `worthpath`.`Revision`.`notes`, `worthpath`.`Revision`.`reason`, `worthpath`.`Revision`.`createdAt`, `worthpath`.`Revision`.`updatedAt` FROM `worthpath`.`Revision` WHERE `worthpath`.`Revision`.`positionId` IN (?,?,?,?,?,?,?,?,?,?) ORDER BY `worthpath`.`Revision`.`effectiveDate` DESC, `worthpath`.`Revision`.`sequence` DESC"
|
||||
],
|
||||
"latestPlan": [
|
||||
{
|
||||
"f0": "-> Append (cost=87744 rows=10) (actual time=0.0191..0.113 rows=10 loops=1)\n -> Stream results (cost=8960 rows=1) (actual time=0.0187..0.0188 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8960 rows=1) (actual time=0.016..0.0161 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'abe0b945-e334-4020-8d28-1d4e03266739' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = 'abe0b945-e334-4020-8d28-1d4e03266739') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8960 rows=19910) (actual time=0.0155..0.0155 rows=1 loops=1)\n -> Stream results (cost=8960 rows=1) (actual time=0.0115..0.0116 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8960 rows=1) (actual time=0.0102..0.0102 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '7a7b7ab6-2d18-4ddc-87d5-b7d673d03616' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '7a7b7ab6-2d18-4ddc-87d5-b7d673d03616') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8960 rows=19910) (actual time=0.01..0.01 rows=1 loops=1)\n -> Stream results (cost=8225 rows=1) (actual time=0.00945..0.00951 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8225 rows=1) (actual time=0.00863..0.00865 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '928ec1d9-dfe2-4636-af8c-9ef882778826' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '928ec1d9-dfe2-4636-af8c-9ef882778826') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8225 rows=18278) (actual time=0.00854..0.00854 rows=1 loops=1)\n -> Stream results (cost=8960 rows=1) (actual time=0.00986..0.00992 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8960 rows=1) (actual time=0.00918..0.0092 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '26113ccf-1d6a-4f2e-bef4-d2b2845fa44e' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '26113ccf-1d6a-4f2e-bef4-d2b2845fa44e') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8960 rows=19910) (actual time=0.00893..0.00893 rows=1 loops=1)\n -> Stream results (cost=8225 rows=1) (actual time=0.0103..0.0103 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8225 rows=1) (actual time=0.00963..0.00965 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '2965aade-bb10-45bc-a172-f6729f591856' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '2965aade-bb10-45bc-a172-f6729f591856') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8225 rows=18278) (actual time=0.00951..0.00951 rows=1 loops=1)\n -> Stream results (cost=8732 rows=1) (actual time=0.0104..0.0105 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8732 rows=1) (actual time=0.00979..0.00981 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'f757987e-153c-44aa-91db-8abe018468f1' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = 'f757987e-153c-44aa-91db-8abe018468f1') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8732 rows=19404) (actual time=0.00964..0.00964 rows=1 loops=1)\n -> Stream results (cost=8225 rows=1) (actual time=0.00932..0.00939 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8225 rows=1) (actual time=0.00839..0.00841 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '2a00d18e-5529-4160-afde-ef02be8af760' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '2a00d18e-5529-4160-afde-ef02be8af760') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=8225 rows=18278) (actual time=0.00831..0.00831 rows=1 loops=1)\n -> Stream results (cost=9349 rows=1) (actual time=0.00764..0.0077 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=9349 rows=1) (actual time=0.00697..0.00699 rows=1 loops=1)\n -> Index range scan on r using Revision_positionId_effectiveDate_sequence_idx over (positionId = '74118fd8-00d5-47fe-974c-db6bf2b966eb' AND effectiveDate < '2026-10-02 16:00:00.000') (reverse), with index condition: ((r.positionId = '74118fd8-00d5-47fe-974c-db6bf2b966eb') and (r.effectiveDate < TIMESTAMP'2026-10-02 16:00:00')) (cost=9349 rows=20774) (actual time=0.00672..0.00672 rows=1 loops=1)\n -> Stream results (cost=8377 rows=1) (actual time=0.00917..0.00924 rows=1 loops=1)\n -> Limit: 1 row(s) (cost=8377 rows=1) (actual time=0.008..0.00802 rowsLine truncated
|
||||
}
|
||||
],
|
||||
"pagePlan": [
|
||||
{
|
||||
"f0": "-> Append (cost=0..0 rows=51) (actual time=0.729..13 rows=51 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.729..0.729 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..170e-6 rows=1 loops=1)\n -> Select #2 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=8955 rows=1) (actual time=0.721..0.721 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'abe0b945-e334-4020-8d28-1d4e03266739' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = 'abe0b945-e334-4020-8d28-1d4e03266739' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 540859) (reverse), with index condition: ((x.positionId = 'abe0b945-e334-4020-8d28-1d4e03266739') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 540859)))) (cost=8955 rows=19899) (actual time=0.72..0.72 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.746..0.746 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=50e-6..70e-6 rows=1 loops=1)\n -> Select #4 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=8955 rows=1) (actual time=0.74..0.74 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '7a7b7ab6-2d18-4ddc-87d5-b7d673d03616' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = '7a7b7ab6-2d18-4ddc-87d5-b7d673d03616' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 530859) (reverse), with index condition: ((x.positionId = '7a7b7ab6-2d18-4ddc-87d5-b7d673d03616') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 530859)))) (cost=8955 rows=19899) (actual time=0.74..0.74 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.747..0.747 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..70e-6 rows=1 loops=1)\n -> Select #6 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=8221 rows=1) (actual time=0.741..0.741 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '928ec1d9-dfe2-4636-af8c-9ef882778826' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = '928ec1d9-dfe2-4636-af8c-9ef882778826' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 520859) (reverse), with index condition: ((x.positionId = '928ec1d9-dfe2-4636-af8c-9ef882778826') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 520859)))) (cost=8221 rows=18267) (actual time=0.741..0.741 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.62..0.62 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #8 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=8955 rows=1) (actual time=0.613..0.613 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '26113ccf-1d6a-4f2e-bef4-d2b2845fa44e' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = '26113ccf-1d6a-4f2e-bef4-d2b2845fa44e' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 510859) (reverse), with index condition: ((x.positionId = '26113ccf-1d6a-4f2e-bef4-d2b2845fa44e') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 510859)))) (cost=8955 rows=19899) (actual time=0.613..0.613 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.626..0.626 rows=1 loops=1)\n -> Rows fetched before execution (cost=0..0 rows=1) (actual time=40e-6..60e-6 rows=1 loops=1)\n -> Select #10 (subquery in projection; run only once)\n -> Limit: 1 row(s) (cost=8221 rows=1) (actual time=0.621..0.621 rows=1 loops=1)\n -> Index range scan on x using Revision_positionId_effectiveDate_sequence_idx over (positionId = '2965aade-bb10-45bc-a172-f6729f591856' AND effectiveDate < '2026-10-01 16:00:00.000') OR (positionId = '2965aade-bb10-45bc-a172-f6729f591856' AND effectiveDate = '2026-10-01 16:00:00.000' AND sequence < 500859) (reverse), with index condition: ((x.positionId = '2965aade-bb10-45bc-a172-f6729f591856') and ((x.effectiveDate < TIMESTAMP'2026-10-01 16:00:00') or ((x.effectiveDate = TIMESTAMP'2026-10-01 16:00:00') and (x.sequence < 500859)))) (cost=8221 rows=18267) (actual time=0.621..0.621 rows=1 loops=1)\n -> Stream results (cost=0..0 rows=1) (actual time=0.644..0.644 rows=1 loops=1)\n Line truncated
|
||||
}
|
||||
],
|
||||
"candidatePlans": [
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3100 rows=51) (actual time=0.0512..0.164 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='26113ccf-1d6a-4f2e-bef4-d2b2845fa44e') (reverse) (cost=3100 rows=19920) (actual time=0.0509..0.162 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=2937 rows=51) (actual time=0.00983..0.104 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='2a00d18e-5529-4160-afde-ef02be8af760') (reverse) (cost=2937 rows=18288) (actual time=0.00967..0.102 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3100 rows=51) (actual time=0.0115..0.111 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='7a7b7ab6-2d18-4ddc-87d5-b7d673d03616') (reverse) (cost=3100 rows=19920) (actual time=0.0113..0.108 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=2937 rows=51) (actual time=0.0128..0.133 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='928ec1d9-dfe2-4636-af8c-9ef882778826') (reverse) (cost=2937 rows=18288) (actual time=0.0126..0.13 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3299 rows=51) (actual time=0.0112..0.123 rows=51 loops=1)\n -> Index range scan on Revision using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'f757987e-153c-44aa-91db-8abe018468f1') (reverse), with index condition: (revision.positionId = 'f757987e-153c-44aa-91db-8abe018468f1') (cost=3299 rows=19414) (actual time=0.011..0.121 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3324 rows=51) (actual time=0.0205..0.141 rows=51 loops=1)\n -> Index range scan on Revision using Revision_positionId_effectiveDate_sequence_idx over (positionId = 'abe0b945-e334-4020-8d28-1d4e03266739') (reverse), with index condition: (revision.positionId = 'abe0b945-e334-4020-8d28-1d4e03266739') (cost=3324 rows=19920) (actual time=0.0203..0.138 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=2971 rows=51) (actual time=0.0266..0.191 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='696e54a9-ee0b-45f0-b634-b08c26e4a902') (reverse) (cost=2971 rows=18624) (actual time=0.0261..0.188 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3418 rows=51) (actual time=0.0229..0.199 rows=51 loops=1)\n -> Index range scan on Revision using Revision_positionId_effectiveDate_sequence_idx over (positionId = '1abba907-af9b-447b-b281-84fc7b08b6d9') (reverse), with index condition: (revision.positionId = '1abba907-af9b-447b-b281-84fc7b08b6d9') (cost=3418 rows=21636) (actual time=0.022..0.196 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=2937 rows=51) (actual time=0.0102..0.0974 rows=51 loops=1)\n -> Index lookup on Revision using Revision_positionId_effectiveDate_sequence_idx (positionId='2965aade-bb10-45bc-a172-f6729f591856') (reverse) (cost=2937 rows=18288) (actual time=0.0101..0.0951 rows=51 loops=1)\n"
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=3360 rows=51) (actual time=0.0184..0.249 rows=51 loops=1)\n -> Index range scan on Revision using Revision_positionId_effectiveDate_sequence_idx over (positionId = '74118fd8-00d5-47fe-974c-db6bf2b966eb') (reverse), with index condition: (revision.positionId = '74118fd8-00d5-47fe-974c-db6bf2b966eb') (cost=3360 rows=20786) (actual time=0.018..0.246 rows=51 loops=1)\n"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# 查询优化、关联记账与多语言验收(2026-10-02)
|
||||
|
||||
实际环境:Node 24.21.0、MySQL 8.0.45、Prisma 6.19.0、NestJS 11.2.6、React 19、Vite 7.3.6、TypeScript 5.9.3,以锁文件为准。保留 MySQL、Decimal 和已有用户隔离规则;归档项目仍参与总额。Revision.amount 是变更后的余额。
|
||||
|
||||
## 查询与计算
|
||||
|
||||
- `/positions?kind=account|asset|debt` 只带当前余额、必要资料、可见关联及本位币折算;`/positions/:id` 返回必要资料与当前原币余额。两者不带历史。
|
||||
- `/overview` 只带当前总资产、负债、净资产、必要明细及汇率状态。
|
||||
- `/history`、`/positions/:id/history`、`/transfers` 支持 `limit`、`cursor`、`from`、`to`;历史另支持 `positionId`。默认 50,最多 100,最近流水使用同一接口 limit=20。返回 `{items,nextCursor,revealed}`。历史按 effectiveDate DESC、唯一 sequence DESC;往来按 effectiveDate DESC、唯一 id DESC。
|
||||
- 历史先从每个可见项目的复合索引取至多 limit+1 条,再合并页;只对入选记录定位真实前序余额,分页首条的 before 不假定为零。项目非常多时,查询数仍随项目数增长。
|
||||
- 最新余额使用按项目分支的参数化 UNION ALL,每个分支按 `[positionId,effectiveDate,sequence]` 反向索引扫描 LIMIT 1;最多 100 个分支一批。没有反规范化当前余额字段,因此无需余额回填或缓存重建,历史补录、更正、导入恢复后直接从历史重算。
|
||||
- 已捕获 Prisma 实际 SQL:单父项目 nested take:1 有数据库 LIMIT;多父项目 nested take:1 产生无 LIMIT 的 revisions 查询,由客户端裁剪。不能仅靠 take 判断查询代价。本次使用明确的数据库分支限制。
|
||||
- 更正只查目标及相关配对约束。不能改配对记录或在其之前插入/更正;同业务时间使用 sequence 比较,配对后同分钟的普通余额记录可以更正。保留业务约束,避免破坏双方账。
|
||||
- `/trend` 默认最近 90 天。day 最多 366 天、week 最多 1096 天、month 最多 3653 天。使用区间前最后余额和汇率作种子,数据库取范围内每日最后余额;应用排序一次并顺序推进。响应不带每日期完整项目明细。
|
||||
- 周以周一至周日、月以自然月取期末余额;变化归因累加该段每日余额变化与汇率变化,首尾不完整周期按实际范围。缺失汇率标记不完整,沿用时保留实际 rateDate。仅查询相关币种、本位币的区间汇率和必要前序汇率;当前总额只查询最新可用汇率。
|
||||
|
||||
## 实测证据
|
||||
|
||||
随机独立测试用户,原服务 3100 与优化后隔离服务 33101 使用同一数据库;每项 3 次取中位数,全部为实际 HTTP 测量,响应大小为未压缩 JSON 字节。测试记录集中在 90 天内,金额虚构,测试结束清理该测试用户。完整 SQL、EXPLAIN ANALYZE、处理函数计时及 HTTP 结果见 [原始证据](performance-final.json)。原始证据中的 HTTP queries=0 表示远端 HTTP 客户端未采集 SQL 事件,不代表数据库零查询。
|
||||
|
||||
| 数据 | 账户列表:前 → 后 | 列表响应:前 → 后 | 当前总览:前 → 后 | 历史 50 条 | 日趋势 90 天 |
|
||||
| -------------- | -----------------: | -------------------: | ------------------: | ---------: | -----------: |
|
||||
| 单账户 1 万 | 288.13 → 11.59 ms | 6,097,025 → 368 B | 992.35 → 7.36 ms | 19.20 ms | 54.64 ms |
|
||||
| 单账户 10 万 | 2,826.90 → 5.56 ms | 61,267,028 → 370 B | 9,885.51 → 5.39 ms | 38.50 ms | 585.14 ms |
|
||||
| 10 账户各 1 万 | 2,869.42 → 6.19 ms | 60,970,241 → 3,671 B | 15,636.96 → 5.48 ms | 34.32 ms | 568.12 ms |
|
||||
|
||||
单账户 10 万条 EXPLAIN ANALYZE:最新余额索引节点实际 rows=1 loops=1;历史候选索引节点 rows=51 loops=1;每条前序余额子查询实际 rows=1。多账户每分支分别限量。资金往来最终采用 FORCE INDEX + STRAIGHT_JOIN,以用户/时间/id 为驱动;60 条同时间测试记录,首批索引实际 rows=51,游标下一页 rows=10,无全量排序,见 [往来分页计划](transfers-plan.json)。隐藏或指定账户过滤需要跳过不匹配行,实际扫描可多于返回条数。旧列表实际返回全部 1 万/10 万历史,旧总览也读取全部历史;没有采集旧查询精确存储引擎 rows_examined,因此不将返回行数冒充扫描统计。新增全局日期索引曾导致优化器选择全局历史路径,已通过第二迁移移除,最终使用项目复合索引。EXPLAIN 中 cost/estimated rows 与 actual rows 含义不同。
|
||||
|
||||
前端请求日志及浏览器实测:总览为 overview、trend、history(limit20) 共 3 个财务 GET;账户列表 1 个;详情 2 个;加载下一页只 1 个 history;变化记录页 2 个;打开资金往来表单才取账户/债务选项。旧统一加载为 positions、overview、transfers、settings 共 4 个 GET,各页面都请求全部历史。总览现在虽为 3 个 GET,但每个职责有界;账户列表从 4 降到 1。语言切换为 0 个财务 GET。认证 me 和交互活动另计;启动 me 通过共享 Promise 避免开发 StrictMode 重复。日期范围改变只请求 trend;过期响应由会话、加载序号和当前选择校验拦截。
|
||||
|
||||
这些是本机对当前数据库的结果,不是生产并发/p95 保证。区间内 10 万条趋势仍需数据库窗口排序,约 0.6 秒;未引入每日快照、缓存或新基础设施。游标页在并发历史更正后应重新加载,未提供跨请求冻结快照。
|
||||
|
||||
## 借入、借出及手续费
|
||||
|
||||
债务 side=liability 表示借入/应付,side=asset 表示借出/应收。资金往来复用 `/transfers`,operation 为 transfer、borrow、lend、collect、repay。来源为启用中的资产账户,目标为另一资产账户或对应方向的债务。同币种账户本金与债务本金相等,跨币种填写实际金额。
|
||||
|
||||
借入到账:账户 +(amount-fee),应付 +received;借出:账户 -(amount+fee),应收 +received;收回应收:账户 +(amount-fee),应收 -received;偿还应付:账户 -(amount+fee),应付 -received。手续费可为负数表示优惠,绝对优惠不超过本金;收款正手续费不超过本金;禁止负余额或超额还款。金额仍为 Decimal。
|
||||
|
||||
双方行按 ID 顺序加锁,最新余额读取、幂等校验、双边历史、往来记录及自动关联同处 Serializable 事务。死锁/写冲突有限重试(最多 3 次重试)。账户详情预选当前账户;债务详情按关联预选账户。借入/借出操作、流水、详情互相可导航。新建债务可先录已有余额;新发生借贷建议建 0 余额后使用联动操作,避免重复计入。
|
||||
|
||||
ZIP v6 增加 operation;保留 v3/v4/v5 ZIP、v1/v2 JSON 读取。恢复校验双边前后余额、操作方向、费用和配对原因,不改变历史金额含义。
|
||||
|
||||
## 三种语言
|
||||
|
||||
顶部及登录页支持简中、English、繁中,语言保存在本浏览器 localStorage。静态词典覆盖页面、表单、图标交互和常见服务端错误;用户名称、备注、原币金额保持原文。繁中由开发时 OpenCC 生成静态 JSON,不在浏览器运行转换。修改英文词典后执行 `node apps/web/scripts/generate-locales.cjs`;安全清空确认短语仍精确要求“确定清空”。
|
||||
|
||||
## 迁移、部署与复验
|
||||
|
||||
本次已对配置数据库执行 migrate deploy,四个迁移:
|
||||
|
||||
1. `20261002000000_query_indexes`:Revision 项目/日期/顺序及配对约束索引、Transfer 用户/日期/id 索引。
|
||||
2. `20261002000100_remove_global_revision_index`:移除实测不合适的全局日期索引。
|
||||
3. `20261002000200_debt_movements`:Transfer.operation 默认 transfer,旧记录自动获得原转账语义。
|
||||
4. `20261002000300_chinese_comments`:8 张业务表及 73 个字段增加中文注释,Prisma 模型同步文档注释。迁移保留 SHOW CREATE TABLE 原列定义,不改变字段类型、默认值和自增;数据库元数据验证见 [注释核验](database-comments.json)。Prisma 自身的迁移元数据表不作为业务表修改。
|
||||
|
||||
无余额列回填;未删除真实历史。其他环境需先备份,停止 API 释放 Windows Prisma DLL,再按下面步骤部署同步后端与前端:
|
||||
|
||||
```powershell
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm db:status
|
||||
pnpm typecheck
|
||||
pnpm build
|
||||
pnpm test
|
||||
# 启动优化后的 API 后
|
||||
$env:TEST_API_URL='http://127.0.0.1:3100/api'
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
最终验证:类型检查和生产构建通过;18 个单元测试、8 个真实 MySQL 集成测试通过。涵盖历史补录/更正、同时间顺序、前序余额/分页、归档、隐藏解锁/用户隔离、缺失汇率/本位币切换、负手续费、并发幂等转账与收款、借入借出/还款、ZIP 完整恢复。浏览器验证了 50+10 条分页、详情转账预选、债务关联导航、还款表单默认账户及中英繁切换/刷新持久化。
|
||||
|
||||
基准可重跑:在 apps/api 执行 `pnpm exec tsx scripts/performance.ts rerun`;TEST_API_URL 指向优化后服务,BASELINE_API_URL 可选指向独立旧版本服务,结果输出 docs/performance-rerun.json。脚本只生成/清理随机测试用户。连续反复认证集成测试可能触发现有每 IP 15 分钟 30 次认证限速;本次通过重启隔离测试进程复验,生产限制未放宽。
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"sql": "\n SELECT t.id FROM Transfer t FORCE INDEX (Transfer_userId_effectiveDate_id_idx)\n STRAIGHT_JOIN Position s ON s.id = t.sourceId\n STRAIGHT_JOIN Position d ON d.id = t.targetId\n WHERE t.userId = ? AND s.userId = ? AND d.userId = ?\n AND s.hidden = false AND d.hidden = false\n \n \n \n \n ORDER BY t.effectiveDate DESC, t.id DESC LIMIT ?",
|
||||
"plan": [
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=30.6 rows=0.6) (actual time=0.022..0.122 rows=51 loops=1)\n -> Nested loop inner join (cost=30.6 rows=0.6) (actual time=0.0216..0.12 rows=51 loops=1)\n -> Nested loop inner join (cost=28.5 rows=6) (actual time=0.0197..0.0934 rows=51 loops=1)\n -> Index lookup on t using Transfer_userId_effectiveDate_id_idx (userId='75778c01-f278-42e1-bc07-85331d1be657') (reverse) (cost=7.5 rows=60) (actual time=0.0155..0.0644 rows=51 loops=1)\n -> Filter: ((s.hidden = false) and (s.userId = '75778c01-f278-42e1-bc07-85331d1be657')) (cost=0.25 rows=0.1) (actual time=391e-6..452e-6 rows=1 loops=51)\n -> Single-row index lookup on s using PRIMARY (id=t.sourceId) (cost=0.25 rows=1) (actual time=124e-6..144e-6 rows=1 loops=51)\n -> Filter: ((d.hidden = false) and (d.userId = '75778c01-f278-42e1-bc07-85331d1be657')) (cost=0.252 rows=0.1) (actual time=345e-6..406e-6 rows=1 loops=51)\n -> Single-row index lookup on d using PRIMARY (id=t.targetId) (cost=0.252 rows=1) (actual time=93.5e-6..113e-6 rows=1 loops=51)\n"
|
||||
}
|
||||
],
|
||||
"cursorSql": "\n SELECT t.id FROM Transfer t FORCE INDEX (Transfer_userId_effectiveDate_id_idx)\n STRAIGHT_JOIN Position s ON s.id = t.sourceId\n STRAIGHT_JOIN Position d ON d.id = t.targetId\n WHERE t.userId = ? AND s.userId = ? AND d.userId = ?\n AND s.hidden = false AND d.hidden = false\n \n \n \n AND (t.effectiveDate < ? OR\n (t.effectiveDate = ? AND t.id < ?))\n ORDER BY t.effectiveDate DESC, t.id DESC LIMIT ?",
|
||||
"cursorPlan": [
|
||||
{
|
||||
"f0": "-> Limit: 51 row(s) (cost=9.7 rows=0.11) (actual time=0.0221..0.046 rows=10 loops=1)\n -> Nested loop inner join (cost=9.7 rows=0.11) (actual time=0.0218..0.0453 rows=10 loops=1)\n -> Nested loop inner join (cost=9.31 rows=1.1) (actual time=0.0199..0.0387 rows=10 loops=1)\n -> Index range scan on t using Transfer_userId_effectiveDate_id_idx over (userId = '75778c01-f278-42e1-bc07-85331d1be657' AND effectiveDate < '2026-10-01 04:00:00.000') OR (userId = '75778c01-f278-42e1-bc07-85331d1be657' AND effectiveDate = '2026-10-01 04:00:00.000' AND id < '475b7d99-f250-44cb-a7ea-d75c9c3d39fa') (reverse), with index condition: ((t.userId = '75778c01-f278-42e1-bc07-85331d1be657') and ((t.effectiveDate < TIMESTAMP'2026-10-01 04:00:00') or ((t.effectiveDate = TIMESTAMP'2026-10-01 04:00:00') and (t.id < '475b7d99-f250-44cb-a7ea-d75c9c3d39fa')))) (cost=5.46 rows=11) (actual time=0.0157..0.0295 rows=10 loops=1)\n -> Filter: ((s.hidden = false) and (s.userId = '75778c01-f278-42e1-bc07-85331d1be657')) (cost=0.251 rows=0.1) (actual time=664e-6..728e-6 rows=1 loops=10)\n -> Single-row index lookup on s using PRIMARY (id=t.sourceId) (cost=0.251 rows=1) (actual time=366e-6..386e-6 rows=1 loops=10)\n -> Filter: ((d.hidden = false) and (d.userId = '75778c01-f278-42e1-bc07-85331d1be657')) (cost=0.259 rows=0.1) (actual time=470e-6..534e-6 rows=1 loops=10)\n -> Single-row index lookup on d using PRIMARY (id=t.targetId) (cost=0.259 rows=1) (actual time=208e-6..230e-6 rows=1 loops=10)\n"
|
||||
}
|
||||
],
|
||||
"candidateRows": 51,
|
||||
"nextPageRows": 10
|
||||
}
|
||||
Generated
+8
@@ -121,6 +121,9 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.0
|
||||
version: 5.2.0(vite@7.3.6(@types/node@24.19.0)(jiti@2.7.0)(tsx@4.23.15))
|
||||
opencc-js:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2
|
||||
typescript:
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.3
|
||||
@@ -1465,6 +1468,9 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
opencc-js@1.4.2:
|
||||
resolution: {integrity: sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2999,6 +3005,8 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
opencc-js@1.4.2: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-to-regexp@8.3.0: {}
|
||||
|
||||
Reference in new issue
Block a user