diff --git a/README.md b/README.md index 8d93d9b..ff23a15 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ if (!(Test-Path apps/api/.env)) { Copy-Item apps/api/.env.example apps/api/.env # 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在 pnpm db:generate pnpm db:migrate -pnpm --filter @worthpath/api icons:seed # 追加预置共享图标,重复执行不覆盖已有记录 +pnpm --filter @worthpath/api build +pnpm --filter @worthpath/api icons:seed # 幂等初始化或更新内置图标,保留用户上传和财务数据 pnpm dev ``` @@ -30,9 +31,9 @@ pnpm db:status pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件 ``` -当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、侧栏及无操作退出设置、安全清空本账号数据。 +当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、各菜单项显示开关、统一备注显示开关及无操作退出设置、安全清空本账号数据。 -金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额,已有错误记录可单独更正。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 +金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。 @@ -40,12 +41,34 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的 设计见 [数据模型与结构](docs/architecture.md),已验证范围与后续工作见 [首版验收](docs/acceptance.md)。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。API 生产启动:在 `apps/api` 中执行 `node dist/main.js`。生产认证限速需迁移到共享存储,数据库用户需最小权限,部署与运行监控尚未配置。 -备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 +备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;旧 JSON 备份继续兼容。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。 账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。 -预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库,不会覆盖已有图标。可离线使用已提交的 PNG,无需访问外部图标网站。 +预置 17 家银行及支付宝、微信、京东金融共 20 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。 -ZIP 格式 v4 增加 icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 +ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 + +## 账户转账与显示设置 + +账户页面提供“账户间转账”,适用于自己启用中的资产账户。双方余额和历史在同一数据库事务中更新;手续费按转出币种额外扣除,同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账。转账时间不能早于双方最新余额;后续余额调整不能插入已有转账之前。负债还款继续使用负债余额更新流程。 + +设置分类使用下拉菜单;资产总览、账户、独立资产、债务、变化记录分别使用显示开关,设置入口始终保留。顶部重复导航已移除。“显示备注”关闭后隐藏列表、详情与表单备注,原有备注不删除,备份仍完整包含。图标通过弹窗检索、选择或上传,透明背景显示;接近纯白的背景像素会转为透明,彩色背景需上传透明 PNG。 + +“清空本账号数据”以红色显示。先下载当前完整备份并确认已保存,再输入“确定清空”,依次完成两次确认弹窗;第一次取消在左、确认在右,第二次确认在左、取消在右,两次默认聚焦取消。服务端仍校验当前登录身份、近期备份凭据及数据完整性,财务数据发生变化后须重新下载备份。 + +## OpenAPI / Swagger + +启动开发服务后可访问: + +- Swagger 交互文档:[http://localhost:5173/api/docs](http://localhost:5173/api/docs) +- OpenAPI JSON:[http://localhost:5173/api/openapi.json](http://localhost:5173/api/openapi.json) +- 后端直连文档:[http://localhost:3100/api/docs](http://localhost:3100/api/docs) + +```powershell +Invoke-RestMethod http://localhost:5173/api/openapi.json +``` + +接口定义由实际控制器路由和表单校验模式生成。文档本身不包含用户财务数据;除注册、登录及健康检查外,业务接口需登录会话 Cookie。建议先在同一站点登录,再通过前端代理地址使用文档;写入接口仍受来源校验和数据隔离约束。部署时将上述地址中的主机替换为自己的站点,并将 `/api` 代理到后端。实现使用 [NestJS Swagger](https://docs.nestjs.com/openapi/introduction)。 diff --git a/apps/api/assets/icons/18.png b/apps/api/assets/icons/18.png index 4aa2c67..d242794 100644 Binary files a/apps/api/assets/icons/18.png and b/apps/api/assets/icons/18.png differ diff --git a/apps/api/assets/icons/sources.json b/apps/api/assets/icons/sources.json index 2c35ef8..5afadf0 100644 --- a/apps/api/assets/icons/sources.json +++ b/apps/api/assets/icons/sources.json @@ -103,9 +103,9 @@ }, { "name": "支付宝", - "source": "https://i.alipayobjects.com/common/favicon/favicon.ico", + "source": "https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg", "file": "18.png", - "sha256": "b662de58b15b34d1bf4d2a8bc546f7062a3faaa22acd2ecbbbad70f1e7f37a39" + "sha256": "8049dde7772116e573864e0d3fab1fd5078c75f88e90dc01f70f63a789889a1a" }, { "name": "微信", diff --git a/apps/api/package.json b/apps/api/package.json index 254ddd4..10d5294 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,13 +9,14 @@ "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:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts", "icons:seed": "node scripts/seed-icons.cjs" }, "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "11.2.6", "@prisma/client": "6.19.0", "archiver": "^8.0.0", "bcryptjs": "^3.0.0", diff --git a/apps/api/prisma/migrations/006_navigation_transfers/migration.sql b/apps/api/prisma/migrations/006_navigation_transfers/migration.sql new file mode 100644 index 0000000..da745db --- /dev/null +++ b/apps/api/prisma/migrations/006_navigation_transfers/migration.sql @@ -0,0 +1,14 @@ +ALTER TABLE `User` ADD COLUMN `hiddenMenus` VARCHAR(128) NOT NULL DEFAULT '', DROP COLUMN `showSidebar`; +CREATE TABLE `Transfer` ( + `id` CHAR(36) NOT NULL, `importedFromId` CHAR(36) NULL, `userId` CHAR(36) NOT NULL, + `sourceId` CHAR(36) NOT NULL, `targetId` CHAR(36) NOT NULL, + `sourceRevisionId` CHAR(36) NOT NULL, `targetRevisionId` CHAR(36) NOT NULL, + `sourceCurrency` CHAR(3) NOT NULL, `targetCurrency` CHAR(3) NOT NULL, + `amount` DECIMAL(24,8) NOT NULL, `received` DECIMAL(24,8) NOT NULL, `fee` DECIMAL(24,8) NOT NULL, + `effectiveDate` DATETIME(3) NOT NULL, `notes` TEXT NOT NULL, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), UNIQUE INDEX `Transfer_sourceRevisionId_key` (`sourceRevisionId`), UNIQUE INDEX `Transfer_targetRevisionId_key` (`targetRevisionId`), + UNIQUE INDEX `Transfer_userId_importedFromId_key` (`userId`,`importedFromId`), INDEX `Transfer_userId_effectiveDate_idx` (`userId`,`effectiveDate`), + CONSTRAINT `Transfer_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `Transfer_sourceId_fkey` FOREIGN KEY (`sourceId`) REFERENCES `Position` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `Transfer_targetId_fkey` FOREIGN KEY (`targetId`) REFERENCES `Position` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/apps/api/prisma/migrations/007_notes_display/migration.sql b/apps/api/prisma/migrations/007_notes_display/migration.sql new file mode 100644 index 0000000..ca14c18 --- /dev/null +++ b/apps/api/prisma/migrations/007_notes_display/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `User` ADD COLUMN `showNotes` BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 6c426f4..001b411 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -10,7 +10,8 @@ model User { username String @unique @db.VarChar(64) passwordHash String @db.VarChar(255) baseCurrency String @default("CNY") @db.Char(3) - showSidebar Boolean @default(true) + hiddenMenus String @default("") @db.VarChar(128) + showNotes Boolean @default(true) idleMinutes Int @default(30) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -18,6 +19,7 @@ model User { rates ExchangeRate[] sessions Session[] icons Icon[] + transfers Transfer[] } model Session { id String @id @db.Char(64) @@ -50,6 +52,8 @@ model Position { revisions Revision[] outgoing PositionLink[] @relation("Source") incoming PositionLink[] @relation("Target") + transfersOut Transfer[] @relation("TransferSource") + transfersIn Transfer[] @relation("TransferTarget") @@index([userId,kind]) @@unique([userId,importedFromId]) } @@ -100,3 +104,26 @@ model Icon { @@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) + effectiveDate DateTime @db.DateTime(3) + notes String @db.Text + createdAt DateTime @default(now()) + @@unique([userId,importedFromId]) + @@index([userId,effectiveDate]) +} diff --git a/apps/api/scripts/download-icons.cjs b/apps/api/scripts/download-icons.cjs index 855170c..e36d051 100644 --- a/apps/api/scripts/download-icons.cjs +++ b/apps/api/scripts/download-icons.cjs @@ -33,7 +33,7 @@ async function main() { source: banks.find((b) => b.name === name + '@3x.png')?.download_url, })); sources.push( - { name: '支付宝', source: 'https://i.alipayobjects.com/common/favicon/favicon.ico' }, + { name: '支付宝', source: 'https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg' }, { name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' }, { name: '京东金融', source: 'https://jr.jd.com/logo.png' }, ); @@ -43,6 +43,9 @@ async function main() { const res = await fetch(item.source, { signal: AbortSignal.timeout(20000) }); if (!res.ok) throw Error('Public icon download failed'); const raw = Buffer.from(await res.arrayBuffer()); + // Alipay's catalog SVG is an inverse mark. Mask within the glyph bounds to omit its frame. + const glyphPath = item.name === '支付宝' ? raw.toString().match(/`) : raw; const file = String(manifest.length + 1).padStart(2, '0') + (item.source.endsWith('.ico') ? '.ico' : '.png'); @@ -50,7 +53,7 @@ async function main() { else await fs.writeFile( path.join(dir, file), - await sharp(raw) + await sharp(rasterSource, { density: 768 }) .resize(256, 256, { fit: 'inside', withoutEnlargement: true }) .png() .toBuffer(), diff --git a/apps/api/scripts/seed-icons.cjs b/apps/api/scripts/seed-icons.cjs index d5da16e..a76d9c7 100644 --- a/apps/api/scripts/seed-icons.cjs +++ b/apps/api/scripts/seed-icons.cjs @@ -3,17 +3,15 @@ const { PrismaClient } = require('@prisma/client'); const { readFile } = require('node:fs/promises'); const { join } = require('node:path'); const { createHash } = require('node:crypto'); -const sharp = require('sharp'); +require('reflect-metadata'); +const { normalizeIcon } = require('../dist/icons'); const db = new PrismaClient(); async function main() { const dir = join(__dirname, '../assets/icons'); const sources = JSON.parse(await readFile(join(dir, 'sources.json'), 'utf8')); - // Deterministic IDs make repeated runs safe, without overwriting existing records. + // Update only deterministic built-in image IDs; preserve user uploads and financial data. for (const item of sources) { - const data = await sharp(await readFile(join(dir, item.file))) - .resize(256, 256, { fit: 'inside', withoutEnlargement: true }) - .png() - .toBuffer(); + const data = await normalizeIcon(await readFile(join(dir, item.file))); const hex = createHash('sha256') .update('worthpath-builtin:' + item.name) .digest('hex'); @@ -28,7 +26,7 @@ async function main() { source: item.source, hash: createHash('sha256').update(data).digest('hex'), }, - update: {}, + update: { data, source: item.source, hash: createHash('sha256').update(data).digest('hex') }, }); } console.log(`Shared icon catalog ready: ${sources.length} icons.`); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 7a183b8..2719eb3 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -156,10 +156,17 @@ export class AuthController { return { username: user.username, baseCurrency: user.baseCurrency }; } @Get('auth/me') async me(@Req() req: UserRequest) { - return this.db.user.findUniqueOrThrow({ + const user = await this.db.user.findUniqueOrThrow({ where: { id: req.userId }, - select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true }, + select: { + username: true, + baseCurrency: true, + hiddenMenus: true, + showNotes: true, + idleMinutes: true, + }, }); + return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) }; } @Post('auth/activity') async activity(@Req() r: UserRequest) { await this.db.session.update({ diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 21c0e36..78532c8 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -25,7 +25,15 @@ import { Prisma } from '@prisma/client'; import Decimal from 'decimal.js'; import { Database } from './database'; import { UserRequest } from './auth'; -import { positionInput, positionMeta, currency, revisionInput, rateInput } from './validation'; +import { + positionInput, + positionMeta, + currency, + revisionInput, + rateInput, + hiddenMenus, + transferInput, +} from './validation'; import { createHash } from 'node:crypto'; import { toBusinessDate } from './validation'; import { iconName, validateStoredIcon } from './icons'; @@ -65,7 +73,12 @@ const backupSchema = z baseCurrency: currency, currencies: z.array(currency).max(10), preferences: z - .object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) }) + .object({ + showSidebar: z.boolean().optional(), + hiddenMenus: hiddenMenus.optional(), + showNotes: z.boolean().optional(), + idleMinutes: z.number().int().min(0).max(1440), + }) .strict() .optional(), icons: z @@ -81,6 +94,19 @@ const backupSchema = z .strict(), ) .optional(), + transfers: z + .array( + transferInput.safeExtend({ + id: z.string().uuid(), + importedFromId: z.string().uuid().nullable().optional(), + sourceRevisionId: z.string().uuid(), + targetRevisionId: z.string().uuid(), + sourceCurrency: currency, + targetCurrency: currency, + createdAt: timestamp, + }), + ) + .optional(), positions: z.array(record), links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()), rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })), @@ -122,6 +148,57 @@ export function validateBackup(raw: unknown) { } if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整'); } + const transferIds = new Set(), + usedRevisions = new Set(); + for (const t of b.transfers || []) { + const source = ids.get(t.sourceId), + target = ids.get(t.targetId); + const origin = t.importedFromId || t.id; + if ( + transferIds.has(origin) || + !source || + !target || + source.kind !== 'account' || + target.kind !== 'account' || + source.side !== 'asset' || + target.side !== 'asset' || + source.currency !== t.sourceCurrency || + target.currency !== t.targetCurrency + ) + throw new BadRequestException('转账关联无效'); + transferIds.add(origin); + if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received)) + throw new BadRequestException('同币种转账金额不一致'); + for (const [p, revId, reason, delta] of [ + [source, t.sourceRevisionId, 'transfer_out', new Decimal(t.amount).plus(t.fee).neg()], + [target, t.targetRevisionId, 'transfer_in', new Decimal(t.received)], + ] as const) { + const ordered = [...p.revisions].sort( + (a, b) => + a.date.localeCompare(b.date) || + (a.sequence || 0) - (b.sequence || 0) || + a.createdAt.localeCompare(b.createdAt), + ); + const index = ordered.findIndex((r) => r.id === revId), + current = ordered[index]; + if ( + usedRevisions.has(revId) || + index < 1 || + !current || + current.reason !== reason || + current.date !== t.date || + !new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta) + ) + throw new BadRequestException('转账历史与双方金额不一致'); + usedRevisions.add(revId); + } + } + if ( + b.positions.some((p) => + p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)), + ) + ) + throw new BadRequestException('缺少配对转账记录'); const links = new Set(); for (const l of b.links) { const s = ids.get(l.sourceId), @@ -187,7 +264,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { const [user, ps, rates] = await Promise.all([ client.user.findUniqueOrThrow({ where: { id: userId }, - select: { baseCurrency: true, showSidebar: true, idleMinutes: true }, + select: { baseCurrency: true, hiddenMenus: true, showNotes: true, idleMinutes: true }, }), client.position.findMany({ where: { userId }, @@ -234,12 +311,17 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { ], }, }); + const transfers = await client.transfer.findMany({ where: { userId } }); return backupSchema.parse({ format: 'worthpath', version: 2, exportedAt: new Date().toISOString(), baseCurrency: user.baseCurrency, - preferences: { showSidebar: user.showSidebar, idleMinutes: user.idleMinutes }, + preferences: { + hiddenMenus: user.hiddenMenus.split(',').filter(Boolean), + showNotes: user.showNotes, + idleMinutes: user.idleMinutes, + }, currencies: [ ...new Set([ user.baseCurrency, @@ -254,6 +336,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { hash: i.hash, image: Buffer.from(i.data).toString('base64'), })), + transfers: transfers.map(({ userId, effectiveDate, ...t }) => ({ + ...t, + amount: t.amount.toString(), + received: t.received.toString(), + fee: t.fee.toString(), + date: businessTime(effectiveDate), + createdAt: t.createdAt.toISOString(), + })), positions, links: ps.flatMap((p) => p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })), @@ -353,6 +443,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { (a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date), ); data.currencies.sort(); + data.transfers?.sort((a, b) => a.id.localeCompare(b.id)); data.icons?.sort((a, b) => a.id.localeCompare(b.id)); return createHash('sha256').update(JSON.stringify(data)).digest('hex'); } @@ -393,6 +484,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0), rates: b.rates.length, icons: (b.icons || []).length, + transfers: (b.transfers || []).length, baseCurrency: b.baseCurrency, currentBaseCurrency: existing.baseCurrency, message: @@ -458,7 +550,8 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { }); iconMapping.set(i.id, row.id); } - const mapping = new Map(); + const mapping = new Map(), + revisionMapping = new Map(); for (const p of b.positions) { const row = await tx.position.create({ data: { @@ -495,7 +588,37 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { }, }); mapping.set(p.id, row.id); + const originals = [...p.revisions].sort( + (a, b) => + a.date.localeCompare(b.date) || + (a.sequence || 0) - (b.sequence || 0) || + a.createdAt.localeCompare(b.createdAt), + ); + const restored = await tx.revision.findMany({ + where: { positionId: row.id }, + orderBy: { sequence: 'asc' }, + }); + originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id)); } + for (const t of b.transfers || []) + await tx.transfer.create({ + data: { + userId: r.userId, + importedFromId: t.importedFromId || t.id, + sourceId: mapping.get(t.sourceId)!, + targetId: mapping.get(t.targetId)!, + sourceRevisionId: revisionMapping.get(t.sourceRevisionId)!, + targetRevisionId: revisionMapping.get(t.targetRevisionId)!, + sourceCurrency: t.sourceCurrency, + targetCurrency: t.targetCurrency, + amount: t.amount, + received: t.received, + fee: t.fee, + effectiveDate: toBusinessDate(t.date), + notes: t.notes, + createdAt: new Date(t.createdAt), + }, + }); for (const l of b.links) await tx.positionLink.create({ data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! }, @@ -516,7 +639,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { if (!ps.length && !rs.length) await tx.user.update({ where: { id: r.userId }, - data: { baseCurrency: b.baseCurrency, ...b.preferences }, + data: { + baseCurrency: b.baseCurrency, + idleMinutes: b.preferences?.idleMinutes, + hiddenMenus: b.preferences?.hiddenMenus?.join(','), + showNotes: b.preferences?.showNotes, + }, }); return { ok: true, positions: b.positions.length }; }, diff --git a/apps/api/src/icons.ts b/apps/api/src/icons.ts index 8ef85a3..c1c6be5 100644 --- a/apps/api/src/icons.ts +++ b/apps/api/src/icons.ts @@ -32,9 +32,20 @@ export async function normalizeIcon(data: Buffer) { const meta = await image.metadata(); if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1) throw Error(); - return await image + const resized = await image .rotate() .resize(256, 256, { fit: 'inside', withoutEnlargement: true }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + // Neutral white logo backgrounds become transparent; coloured marks remain unchanged. + const { data: pixels, info } = resized; + for (let i = 0; i < pixels.length; i += 4) { + const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]); + const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]); + if (low >= 245 && high - low <= 8) pixels[i + 3] = 0; + } + return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } }) .png() .toBuffer(); } catch { @@ -95,7 +106,7 @@ export class IconsController { if (!icon) throw new NotFoundException('图标不存在'); res.setHeader('Content-Type', 'image/png'); res.setHeader('X-Content-Type-Options', 'nosniff'); - res.send(Buffer.from(icon.data)); + res.send(await normalizeIcon(Buffer.from(icon.data))); } @Post('upload') @UseInterceptors( diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6d6d464..7340a3a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,11 +1,13 @@ import 'reflect-metadata'; import 'dotenv/config'; +import { setupOpenApi } from './openapi'; import { Module, Catch, ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common'; import { NestFactory, APP_GUARD } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import helmet from 'helmet'; import { json } from 'express'; import { AuthController, AuthGuard, AuthService } from './auth'; +import { TransfersController } from './transfers'; import { PortfolioController } from './portfolio'; import { BackupController } from './backup'; import { IconsController, IconsService } from './icons'; @@ -50,6 +52,7 @@ class SafeErrors implements ExceptionFilter { { provide: APP_GUARD, useClass: AuthGuard }, ], controllers: [ + TransfersController, IconsController, AuthController, PortfolioController, @@ -74,6 +77,7 @@ async function bootstrap() { next(); }); app.useGlobalFilters(new SafeErrors()); + setupOpenApi(app); app.enableShutdownHooks(); await app.listen(Number(process.env.PORT || 3100), '0.0.0.0'); console.log('WorthPath API ready'); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..a68e69e --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,118 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { z } from 'zod'; +import { + credentials, + positionInput, + positionMeta, + revisionInput, + transferInput, + currency, + hiddenMenus, +} from './validation'; +export function setupOpenApi(app: INestApplication) { + const document = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('WorthPath API') + .setVersion('1.1') + .setDescription( + '个人资产管理接口。金额使用十进制字符串;登录会话由 HttpOnly Cookie 传递,数据归属由服务端验证。', + ) + .addCookieAuth('wp_session', { type: 'apiKey', in: 'cookie' }, 'session') + .addSecurityRequirements('session') + .build(), + ); + const bodies: Record = { + 'POST /api/auth/register': credentials, + 'POST /api/auth/login': credentials, + 'POST /api/auth/reveal': credentials.pick({ password: true }), + 'POST /api/positions': positionInput, + 'PATCH /api/positions/{id}': positionMeta, + 'POST /api/positions/{id}/revisions': revisionInput, + 'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput, + 'PUT /api/positions/{id}/links': z + .object({ targetIds: z.array(z.string().uuid()).max(20) }) + .strict(), + 'POST /api/transfers': transferInput, + 'PATCH /api/settings': z + .object({ + baseCurrency: currency.optional(), + hiddenMenus: hiddenMenus.optional(), + showNotes: z.boolean().optional(), + idleMinutes: z.number().int().min(0).max(1440).optional(), + }) + .strict(), + 'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }), + 'POST /api/backup/import-file': z.object({ + token: z.string().uuid(), + confirmed: z.literal(true), + }), + }; + for (const [path, entry] of Object.entries(document.paths)) { + for (const method of ['get', 'post', 'patch', 'put'] as const) { + const operation = entry[method]; + if (!operation) continue; + operation.summary = `${method.toUpperCase()} ${path}`; + if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path)) + operation.security = []; + const schema = bodies[method.toUpperCase() + ' ' + path]; + if (schema) + operation.requestBody = { + required: true, + content: { + 'application/json': { + schema: z.toJSONSchema(schema, { target: 'openapi-3.0' }) as any, + }, + }, + }; + operation.responses['400'] = { + description: '请求格式、金额或业务校验失败,返回 { message }', + }; + operation.responses['401'] = { description: '未登录或会话失效' }; + operation.responses['403'] = { description: '请求来源或访问权限不受信任' }; + operation.responses['409'] = { description: '数据冲突或业务记录不可修改' }; + if (path === '/api/backup' && method === 'get') + operation.responses['200'] = { + description: '该用户全部数据的 ZIP 文件', + content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } }, + }; + if (path === '/api/backup/upload' || path === '/api/icons/upload') { + const properties = { + file: { type: 'string', format: 'binary' }, + ...(path.includes('icons') + ? { + name: { type: 'string', description: '共享图标必须包含中文' }, + shared: { type: 'string', enum: ['false', 'true'], default: 'false' }, + confirmed: { type: 'string', enum: ['true'], description: '共享发布必须确认' }, + } + : {}), + }; + operation.requestBody = { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: path.includes('icons') ? ['file', 'name'] : ['file'], + properties: properties as any, + }, + }, + }, + }; + } + if (['/api/backup/preview', '/api/backup/import'].includes(path)) + operation.requestBody = { + required: true, + description: '旧版 JSON 兼容入口;新版请使用 upload + import-file(支持完整 ZIP 备份)', + content: { + 'application/json': { schema: { type: 'object', additionalProperties: true } }, + }, + }; + } + } + SwaggerModule.setup('api/docs', app, document, { + jsonDocumentUrl: 'api/openapi.json', + swaggerOptions: { persistAuthorization: false, validatorUrl: null, withCredentials: true }, + }); +} diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 9690249..0d69359 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -102,6 +102,7 @@ export class PortfolioController { @Body() b: unknown, ) { const v = revisionInput.parse(b); + if (v.reason.startsWith('transfer_')) throw new BadRequestException('请使用账户转账接口'); return this.db.$transaction( async (tx) => { const p = await tx.position.findFirst({ @@ -109,6 +110,12 @@ export class PortfolioController { }); if (!p) throw new NotFoundException('项目不存在'); if (p.archived) throw new ConflictException('请先恢复归档项目'); + const lastTransfer = await tx.revision.findFirst({ + where: { positionId: p.id, reason: { in: ['transfer_out', 'transfer_in'] } }, + orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], + }); + if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate) + throw new ConflictException('余额调整时间不能早于已有转账;请使用当前时间调整'); if (v.reason === 'repayment') { if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债'); const prior = await tx.revision.findFirst({ @@ -137,22 +144,43 @@ export class PortfolioController { @Param('revisionId') revisionId: string, @Body() b: unknown, ) { - const v = revisionInput.parse(b), - p = await this.own(r.userId, id, r.revealed); - if (p.archived) throw new ConflictException('请先恢复归档项目'); - if (!p.revisions.some((x) => x.id === revisionId)) - throw new NotFoundException('历史记录不存在'); - await this.db.revision.update({ - where: { id: revisionId }, - data: { - amount: v.amount, - effectiveDate: toBusinessDate(v.date), - notes: v.notes, - reason: 'correction', + const v = revisionInput.parse(b); + return this.db.$transaction( + async (tx) => { + const p = await tx.position.findFirst({ + where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, + include: { revisions: true }, + }); + if (!p) throw new NotFoundException('项目不存在'); + if (p.archived) throw new ConflictException('请先恢复归档项目'); + const original = p.revisions.find((h) => h.id === revisionId); + if (!original) throw new NotFoundException('历史记录不存在'); + if ( + p.revisions.some( + (h) => + h.reason.startsWith('transfer_') && + (+h.effectiveDate >= +original.effectiveDate || + toBusinessDate(v.date) < h.effectiveDate), + ) + ) + throw new ConflictException( + '转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致', + ); + await tx.revision.update({ + where: { id: revisionId }, + data: { + amount: v.amount, + effectiveDate: toBusinessDate(v.date), + notes: v.notes, + reason: 'correction', + }, + }); + return { ok: true }; }, - }); - return { ok: true }; + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); } + @Put('positions/:id/links') async link( @Req() r: UserRequest, @Param('id') id: string, diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index 50e8eb9..6d6ff66 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -12,7 +12,7 @@ import { } from '@nestjs/common'; import { Database } from './database'; import { UserRequest } from './auth'; -import { currency, date, rateValue, today } from './validation'; +import { currency, date, rateValue, today, hiddenMenus } from './validation'; import { z } from 'zod'; import Decimal from 'decimal.js'; // Fixed public request; no user currency choices, identifiers or amounts leave the server. @@ -168,10 +168,17 @@ export class SettingsController { @Get('settings') async settings(@Req() r: UserRequest) { const u = await this.db.user.findUniqueOrThrow({ where: { id: r.userId }, - select: { username: true, baseCurrency: true, showSidebar: true, idleMinutes: true }, + select: { + username: true, + baseCurrency: true, + hiddenMenus: true, + showNotes: true, + idleMinutes: true, + }, }); return { ...u, + hiddenMenus: u.hiddenMenus.split(',').filter(Boolean), lastActivity: (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } })) .lastActivity, revealed: r.revealed, @@ -190,13 +197,17 @@ export class SettingsController { const data = z .object({ baseCurrency: currency.optional(), - showSidebar: z.boolean().optional(), + hiddenMenus: hiddenMenus.optional(), + showNotes: z.boolean().optional(), idleMinutes: z.number().int().min(0).max(1440).optional(), }) .strict() .refine((v) => Object.keys(v).length > 0) .parse(b); - await this.db.user.update({ where: { id: r.userId }, data }); + await this.db.user.update({ + where: { id: r.userId }, + data: { ...data, hiddenMenus: data.hiddenMenus?.join(',') }, + }); this.fx.invalidate(r.userId); return { ok: true }; } diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts new file mode 100644 index 0000000..1d7f956 --- /dev/null +++ b/apps/api/src/transfers.ts @@ -0,0 +1,121 @@ +import { + Controller, + Get, + Post, + Body, + Req, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import Decimal from 'decimal.js'; +import { Database } from './database'; +import { UserRequest } from './auth'; +import { transferInput, toBusinessDate } from './validation'; +import { businessTime } from './calculation'; +@Controller('api/transfers') +export class TransfersController { + constructor(private db: Database) {} + @Get() async list(@Req() r: UserRequest) { + const visibility = { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }; + const rows = await this.db.transfer.findMany({ + where: { userId: r.userId, source: visibility, target: visibility }, + include: { source: { select: { name: true } }, target: { select: { name: true } } }, + orderBy: [{ effectiveDate: 'desc' }, { createdAt: 'desc' }], + }); + return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({ + ...v, + date: businessTime(effectiveDate), + })); + } + @Post() async create(@Req() r: UserRequest, @Body() body: unknown) { + const v = transferInput.parse(body), + when = toBusinessDate(v.date); + return this.db.$transaction( + async (tx) => { + if (v.requestId) { + const existing = await tx.transfer.findFirst({ + where: { id: v.requestId, userId: r.userId }, + }); + if (existing) { + if ( + existing.sourceId !== v.sourceId || + existing.targetId !== v.targetId || + !new Decimal(existing.amount.toString()).eq(v.amount) || + !new Decimal(existing.received.toString()).eq(v.received) || + !new Decimal(existing.fee.toString()).eq(v.fee) || + +existing.effectiveDate !== +when || + existing.notes !== v.notes + ) + throw new ConflictException('转账请求标识已使用,请刷新后重试'); + return { id: existing.id }; + } + } + const accounts = await tx.position.findMany({ + where: { + id: { in: [v.sourceId, v.targetId] }, + userId: r.userId, + kind: 'account', + side: 'asset', + archived: false, + ...(r.revealed ? {} : { hidden: false }), + }, + include: { + revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 }, + }, + }); + if (accounts.length !== 2) + throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)'); + const source = accounts.find((p) => p.id === v.sourceId)!, + target = accounts.find((p) => p.id === v.targetId)!; + if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when)) + throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账'); + if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received)) + throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写'); + const debit = new Decimal(v.amount).plus(v.fee), + before = new Decimal(source.revisions[0].amount.toString()); + if (before.lt(debit)) throw new BadRequestException('转出账户余额不足(含手续费)'); + const after = new Decimal(target.revisions[0].amount.toString()).plus(v.received); + if (after.gte('10000000000000000')) + throw new BadRequestException('到账后的金额超出支持范围'); + const outgoing = await tx.revision.create({ + data: { + positionId: source.id, + amount: before.minus(debit).toFixed(), + effectiveDate: when, + notes: v.notes, + reason: 'transfer_out', + }, + }); + const incoming = await tx.revision.create({ + data: { + positionId: target.id, + amount: after.toFixed(), + effectiveDate: when, + notes: v.notes, + reason: 'transfer_in', + }, + }); + const row = await tx.transfer.create({ + data: { + id: v.requestId, + userId: r.userId, + sourceId: source.id, + targetId: target.id, + sourceRevisionId: outgoing.id, + targetRevisionId: incoming.id, + sourceCurrency: source.currency, + targetCurrency: target.currency, + amount: v.amount, + received: v.received, + fee: v.fee, + effectiveDate: when, + notes: v.notes, + }, + }); + return { id: row.id }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } +} diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index b406f7c..a3a653f 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -56,7 +56,15 @@ export const revisionInput = z date: businessDate, notes, reason: z - .enum(['initial', 'balance', 'valuation', 'repayment', 'correction']) + .enum([ + 'initial', + 'balance', + 'valuation', + 'repayment', + 'correction', + 'transfer_out', + 'transfer_in', + ]) .default('balance'), }) .strict(); @@ -108,3 +116,22 @@ export const credentials = z .refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'), }) .strict(); + +export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history']); +export const hiddenMenus = z + .array(menuKey) + .max(5) + .refine((v) => new Set(v).size === v.length, '菜单不可重复'); +export const transferInput = z + .object({ + requestId: z.string().uuid().optional(), + sourceId: z.string().uuid(), + targetId: z.string().uuid(), + amount: amount.refine((v) => /[1-9]/.test(v), '转出金额必须大于零'), + received: amount.refine((v) => /[1-9]/.test(v), '到账金额必须大于零'), + fee: amount.default('0'), + date: businessDate, + notes, + }) + .strict() + .refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账'); diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index 9d9f496..cdb1ac8 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -16,6 +16,7 @@ const files = [ 'links.json', 'rates.json', 'icons.json', + 'transfers.json', ] as const; const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex'); export function packBackup(b: Backup) { @@ -32,6 +33,7 @@ export function packBackup(b: Backup) { 'links.json': b.links, 'rates.json': b.rates, 'icons.json': b.icons || [], + 'transfers.json': b.transfers || [], }; const contents = Object.fromEntries( files.map((name) => [name, JSON.stringify(data[name], null, 2)]), @@ -39,7 +41,7 @@ export function packBackup(b: Backup) { contents['manifest.json'] = JSON.stringify( { format: 'worthpath', - version: 4, + version: 5, exportedAt: b.exportedAt, files: files.map((name) => ({ name, sha256: sha(contents[name]) })), }, @@ -114,18 +116,22 @@ export async function readBackupZip(input: string | Buffer): Promise { const manifest = z .object({ format: z.literal('worthpath'), - version: z.union([z.literal(3), z.literal(4)]), + version: z.union([z.literal(3), z.literal(4), z.literal(5)]), exportedAt: z.iso.datetime(), files: z .array( z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(), ) - .min(files.length - 1) + .min(files.length - 2) .max(files.length), }) .strict() .parse(parse('manifest.json')); - const expected = manifest.version === 3 ? files.filter((f) => f !== 'icons.json') : [...files]; + const expected = files.filter( + (f) => + (manifest.version >= 4 || f !== 'icons.json') && + (manifest.version >= 5 || f !== 'transfers.json'), + ); if ( contents.size !== expected.length + 1 || new Set(manifest.files.map((f) => f.name)).size !== expected.length || @@ -137,7 +143,12 @@ export async function readBackupZip(input: string | Buffer): Promise { .object({ baseCurrency: z.string(), preferences: z - .object({ showSidebar: z.boolean(), idleMinutes: z.number().int().min(0).max(1440) }) + .object({ + showSidebar: z.boolean().optional(), + hiddenMenus: z.array(z.string()).optional(), + showNotes: z.boolean().optional(), + idleMinutes: z.number().int().min(0).max(1440), + }) .strict() .optional(), }) @@ -169,7 +180,8 @@ export async function readBackupZip(input: string | Buffer): Promise { positions: positions.map((p) => ({ ...p, revisions: grouped.get(p.id) || [] })), links: parse('links.json'), rates: parse('rates.json'), - ...(manifest.version === 4 ? { icons: parse('icons.json') } : {}), + ...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}), + ...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}), }; } catch { throw new BadRequestException( diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts index 312aa3a..1d31916 100644 --- a/apps/api/test/integration.test.ts +++ b/apps/api/test/integration.test.ts @@ -53,7 +53,7 @@ test('real MySQL: authentication, isolation, history, backup and atomic failures 'Content-Type': 'application/json', Origin: 'https://untrusted.invalid', }, - body: JSON.stringify({ showSidebar: true }), + body: JSON.stringify({ hiddenMenus: [] }), }) ).status, process.env.WEB_ORIGIN === '*' ? 200 : 403, diff --git a/apps/api/test/privacy.test.ts b/apps/api/test/privacy.test.ts index 1269832..e8bd812 100644 --- a/apps/api/test/privacy.test.ts +++ b/apps/api/test/privacy.test.ts @@ -236,11 +236,12 @@ test('privacy, minute history, backup-gated clear and idle sessions remain user assert.equal(await db.position.count({ where: { userId: b.id } }), 3); assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 200); assert.equal( - (await call('/settings', 'PATCH', { showSidebar: false, idleMinutes: 1 }, a.cookie)).status, + (await call('/settings', 'PATCH', { hiddenMenus: ['asset'], idleMinutes: 1 }, a.cookie)) + .status, 200, ); const prefs = (await call('/settings', 'GET', undefined, a.cookie)).data; - assert.equal(prefs.showSidebar, false); + assert.deepEqual(prefs.hiddenMenus, ['asset']); assert.equal(prefs.idleMinutes, 1); assert.equal((await call('/settings', 'PATCH', { idleMinutes: -1 }, a.cookie)).status, 400); await db.session.update({ diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts new file mode 100644 index 0000000..71aee05 --- /dev/null +++ b/apps/api/test/transfers.test.ts @@ -0,0 +1,292 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { readBackupZip } from '../src/zip'; +import { normalizeIcon } from '../src/icons'; +import sharp from 'sharp'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; +test('transfers are atomic, scoped, retry-safe, decimal exact and included in backups', async () => { + const db = new PrismaClient(), + names: string[] = []; + async function call(path: string, cookie = '', method = 'GET', data?: unknown) { + const r = await fetch(base + path, { + method, + headers: { + Cookie: cookie, + Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!, + ...(data ? { 'Content-Type': 'application/json' } : {}), + }, + body: data ? JSON.stringify(data) : undefined, + }); + return { + status: r.status, + data: r.headers.get('content-type')?.includes('application/zip') + ? ((await readBackupZip(Buffer.from(await r.arrayBuffer()))) as any) + : await r.json(), + cookie: r.headers.get('set-cookie')?.split(';')[0] || '', + }; + } + async function account() { + const username = 'wp_transfer_' + randomUUID(), + password = randomBytes(18).toString('hex'); + names.push(username); + const r = await call('/auth/register', '', 'POST', { username, password }); + assert.equal(r.status, 201); + return { ...r, id: (await db.user.findUniqueOrThrow({ where: { username } })).id, password }; + } + async function position( + cookie: string, + name: string, + amount = '100', + currency = 'CNY', + side = 'asset', + ) { + const r = await call('/positions', cookie, 'POST', { + name, + kind: 'account', + category: side === 'asset' ? 'bank' : 'credit_card', + side, + currency, + amount, + date: '2026-09-01T10:35', + }); + assert.equal(r.status, 201); + return r.data.id as string; + } + try { + const a = await account(), + b = await account(); + const sourceId = await position(a.cookie, '转出账户'), + targetId = await position(a.cookie, '转入账户', '10'), + other = await position(b.cookie, '他人账户'); + const request = { + sourceId, + targetId, + amount: '25', + received: '25', + fee: '1', + date: '2026-09-02T10:35', + notes: '转账验收', + requestId: randomUUID(), + }; + assert.equal((await call('/transfers', '', 'POST', request)).status, 401); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, targetId: other })).status, + 400, + ); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, sourceId: targetId })).status, + 400, + ); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, userId: b.id })).status, + 400, + ); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' })) + .status, + 400, + ); + assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0); + assert.equal(await db.revision.count({ where: { positionId: sourceId } }), 1); + assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201); + assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201); + assert.equal(await db.transfer.count({ where: { userId: a.id } }), 1); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, amount: '24', received: '24' })) + .status, + 409, + ); + const rows = (await call('/positions', a.cookie)).data; + 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(`/positions/${sourceId}/revisions/${revision.id}`, a.cookie, 'PUT', { + amount: '70', + date: request.date, + }) + ).status, + 409, + ); + assert.equal( + ( + await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', { + amount: '70', + date: request.date, + reason: 'transfer_out', + }) + ).status, + 400, + ); + assert.equal( + ( + await call(`/positions/${sourceId}/revisions`, a.cookie, 'POST', { + amount: '80', + date: '2026-09-01T12:00', + reason: 'balance', + }) + ).status, + 409, + ); + assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400); + const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability'); + assert.equal( + ( + await call('/transfers', a.cookie, 'POST', { + ...request, + requestId: randomUUID(), + targetId: debt, + }) + ).status, + 400, + ); + assert.equal( + ( + await call('/transfers', a.cookie, 'POST', { + ...request, + requestId: randomUUID(), + date: '2026-09-01T10:34', + }) + ).status, + 409, + ); + const usd = await position(a.cookie, '美元账户', '20', 'USD'); + assert.equal( + ( + await call('/transfers', a.cookie, 'POST', { + ...request, + requestId: randomUUID(), + sourceId: usd, + amount: '1.00000001', + received: '7.10000001', + fee: '0', + date: '2026-09-02T11:00', + }) + ).status, + 201, + ); + assert.equal( + (await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount, + '18.99999999', + ); + const race = await Promise.all( + [1, 2].map(() => + call('/transfers', a.cookie, 'POST', { + ...request, + requestId: randomUUID(), + sourceId: usd, + amount: '15', + received: '100', + fee: '0', + date: '2026-09-03T10:00', + }), + ), + ); + assert.equal(race.filter((r) => r.status === 201).length, 1); + assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1); + assert.equal( + ( + await call('/settings', a.cookie, 'PATCH', { + hiddenMenus: ['asset', 'debt'], + showNotes: false, + idleMinutes: 15, + }) + ).status, + 200, + ); + assert.deepEqual((await call('/settings', a.cookie)).data.hiddenMenus, ['asset', 'debt']); + assert.equal((await call('/settings', a.cookie)).data.showNotes, false); + assert.equal( + (await call('/settings', a.cookie, 'PATCH', { hiddenMenus: ['settings'] })).status, + 400, + ); + assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400); + const backup = (await call('/backup', a.cookie)).data; + assert.equal(backup.transfers.length, 3); + assert.equal(backup.preferences.showNotes, false); + assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']); + const broken = structuredClone(backup); + broken.transfers[0].amount = '999'; + assert.equal( + (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: broken })).status, + 400, + ); + assert.equal(await db.transfer.count({ where: { userId: b.id } }), 0); + assert.equal( + (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status, + 201, + ); + const restored = (await call('/backup', b.cookie)).data; + assert.equal(restored.transfers.length, 3); + // Appending into an existing space preserves its display preferences. + assert.equal((await call('/settings', b.cookie)).data.showNotes, true); + const c = await account(); + assert.equal( + (await call('/backup/import', c.cookie, 'POST', { confirmed: true, backup })).status, + 201, + ); + assert.equal((await call('/settings', c.cookie)).data.showNotes, false); + for (const t of restored.transfers) { + assert.ok( + restored.positions + .find((p: any) => p.id === t.sourceId) + ?.revisions.some((r: any) => r.id === t.sourceRevisionId), + ); + assert.ok( + restored.positions + .find((p: any) => p.id === t.targetId) + ?.revisions.some((r: any) => r.id === t.targetRevisionId), + ); + } + await call('/positions/' + sourceId, a.cookie, 'PATCH', { + name: '转出账户', + category: 'bank', + hidden: true, + }); + const visibleTransfers = (await call('/transfers', a.cookie)).data; + assert.equal(visibleTransfers.length, 2); + assert.ok( + visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId), + ); + assert.equal( + (await call('/transfers', a.cookie, 'POST', { ...request, requestId: randomUUID() })).status, + 400, + ); + } finally { + await db.user.deleteMany({ where: { username: { in: names } } }); + await db.$disconnect(); + } +}); +test('icon processing creates transparent white cutouts and preserves brand colour', async () => { + const pixels = Buffer.from([255, 255, 255, 255, 10, 120, 60, 255]); + const input = await sharp(pixels, { raw: { width: 2, height: 1, channels: 4 } }) + .png() + .toBuffer(); + const output = await sharp(await normalizeIcon(input)) + .ensureAlpha() + .raw() + .toBuffer(); + assert.equal(output[3], 0); + assert.deepEqual([...output.subarray(4)], [10, 120, 60, 255]); +}); +test('OpenAPI exposes authenticated routes and actual transfer request fields', async () => { + const r = await fetch(base + '/openapi.json'); + assert.equal(r.status, 200); + const doc = await r.json(); + assert.ok(doc.paths['/api/transfers']); + assert.ok( + doc.paths['/api/transfers'].post.requestBody.content['application/json'].schema.properties + .requestId, + ); + assert.equal(doc.components.securitySchemes.session.in, 'cookie'); + assert.doesNotMatch(JSON.stringify(doc), /passwordHash|DATABASE_URL|ghp_/); + const html = await fetch(base + '/docs'); + assert.equal(html.status, 200); + assert.match(await html.text(), /swagger-ui/); +}); diff --git a/apps/api/test/zip.test.ts b/apps/api/test/zip.test.ts index b79ad49..595dbae 100644 --- a/apps/api/test/zip.test.ts +++ b/apps/api/test/zip.test.ts @@ -10,9 +10,10 @@ const empty = () => version: 2, exportedAt: new Date().toISOString(), baseCurrency: 'CNY', - preferences: { showSidebar: false, idleMinutes: 9 }, + preferences: { hiddenMenus: ['asset'], showNotes: false, idleMinutes: 9 }, currencies: ['CNY'], icons: [], + transfers: [], positions: [], rates: [], links: [], @@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat 'manifest.json', 'rates.json', 'settings.json', + 'transfers.json', ]); assert.doesNotMatch(JSON.stringify(contents), /password|token|session|userId/i); assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b); @@ -99,11 +101,28 @@ test('backup accepts over 1000 positions, 10000 revisions per position and 20000 test('legacy v3 ZIP remains readable without icons', async () => { const contents = packBackup(empty()); delete contents['icons.json']; + delete contents['transfers.json']; const manifest = JSON.parse(contents['manifest.json']); manifest.version = 3; - manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'icons.json'); + manifest.files = manifest.files.filter( + (f: { name: string }) => !['icons.json', 'transfers.json'].includes(f.name), + ); contents['manifest.json'] = JSON.stringify(manifest); const restored = validateBackup(await readBackupZip(await archive(contents))); assert.equal(restored.icons, undefined); assert.deepEqual(restored.positions, []); }); + +test('legacy v4 ZIP remains readable without transfers', async () => { + const contents = packBackup(empty()); + delete contents['transfers.json']; + const manifest = JSON.parse(contents['manifest.json']); + manifest.version = 4; + manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'transfers.json'); + contents['manifest.json'] = JSON.stringify(manifest); + const restored = validateBackup(await readBackupZip(await archive(contents))); + assert.deepEqual(restored.icons, []); + assert.equal(restored.transfers, undefined); + assert.equal(restored.preferences?.showNotes, false); + assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bfe2ee9..8f4ecc7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -33,6 +33,9 @@ import { type Total, } from './api'; import './style.css'; +import { IconPicker } from './IconPicker'; +import { TransferForm } from './TransferForm'; +import type { Transfer } from './api'; import { IconLibrary, iconUrl } from './IconLibrary'; const labels: Record = { overview: '资产总览', @@ -77,7 +80,7 @@ function Currency({ value, onChange }: { value: string; onChange: (v: string) => } function Field({ label, children }: { label: string; children: ReactNode }) { return ( -