From 99174a3da5091d3ce930352c3b3071a6a7157af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Thu, 1 Oct 2026 16:21:16 +0800 Subject: [PATCH] feat(api): implement secure portfolio history, daily FX and atomic backups --- .prettierignore | 4 + .prettierrc.json | 1 + README.md | 2 +- apps/api/.env.example | 2 +- apps/api/package.json | 21 +- .../202610010002_import_origin/migration.sql | 3 + apps/api/prisma/schema.prisma | 2 + apps/api/scripts/database.cjs | 19 ++ apps/api/scripts/db-preflight.cjs | 43 ++- apps/api/scripts/dev.cjs | 13 + apps/api/src/auth.ts | 142 ++++++++ apps/api/src/backup.ts | 290 ++++++++++++++++ apps/api/src/calculation.ts | 136 ++++++++ apps/api/src/database.ts | 11 + apps/api/src/main.ts | 73 +++- apps/api/src/portfolio.ts | 175 ++++++++++ apps/api/src/rates.ts | 192 +++++++++++ apps/api/src/validation.ts | 95 ++++++ apps/api/test/calculation.test.ts | 136 ++++++++ apps/api/test/integration.test.ts | 319 ++++++++++++++++++ apps/api/tsconfig.json | 6 +- apps/web/vite.config.ts | 7 +- package.json | 5 +- pnpm-lock.yaml | 52 ++- scripts/check-staged.mjs | 29 ++ 25 files changed, 1750 insertions(+), 28 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 apps/api/prisma/migrations/202610010002_import_origin/migration.sql create mode 100644 apps/api/scripts/database.cjs create mode 100644 apps/api/scripts/dev.cjs create mode 100644 apps/api/src/auth.ts create mode 100644 apps/api/src/backup.ts create mode 100644 apps/api/src/calculation.ts create mode 100644 apps/api/src/database.ts create mode 100644 apps/api/src/portfolio.ts create mode 100644 apps/api/src/rates.ts create mode 100644 apps/api/src/validation.ts create mode 100644 apps/api/test/calculation.test.ts create mode 100644 apps/api/test/integration.test.ts create mode 100644 scripts/check-staged.mjs diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f03f7a5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +**/node_modules/** +**/dist/** +**/.env* +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..058b07d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{"singleQuote":true,"trailingComma":"all","printWidth":100} diff --git a/README.md b/README.md index 4d106ad..53a99ed 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ pnpm db:migrate pnpm dev ``` -前端 http://localhost:5173,API http://localhost:3000/api。 +前端 http://localhost:5173,API http://localhost:3100/api。 ```powershell pnpm typecheck diff --git a/apps/api/.env.example b/apps/api/.env.example index b6d9f21..3fd7cfa 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,4 +1,4 @@ DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath" -PORT=3000 +PORT=3100 WEB_ORIGIN=http://localhost:5173 COOKIE_SECURE=false diff --git a/apps/api/package.json b/apps/api/package.json index ec51f90..3c43b15 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,12 +2,14 @@ "name": "@worthpath/api", "private": true, "scripts": { - "dev": "tsx watch src/main.ts", + "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test test/*.test.ts", + "test": "tsx --test test/calculation.test.ts", "db:generate": "prisma generate", - "db:migrate": "prisma migrate deploy" + "db:migrate": "node scripts/database.cjs deploy", + "db:status": "node scripts/database.cjs status", + "test:integration": "tsx --test test/integration.test.ts" }, "dependencies": { "@nestjs/common": "^11.0.0", @@ -18,18 +20,19 @@ "cookie-parser": "^1.4.7", "decimal.js": "^10.6.0", "dotenv": "^17.2.0", + "express": "5.1.0", "helmet": "^8.1.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "zod": "^4.1.0" }, "devDependencies": { + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.0", + "@types/node": "^24.0.0", + "mysql2": "^3.15.0", "prisma": "6.19.0", "tsx": "^4.20.0", - "typescript": "^5.9.0", - "@types/node": "^24.0.0", - "@types/express": "^5.0.0", - "@types/cookie-parser": "^1.4.9", - "mysql2": "^3.15.0" + "typescript": "^5.9.0" } -} \ No newline at end of file +} diff --git a/apps/api/prisma/migrations/202610010002_import_origin/migration.sql b/apps/api/prisma/migrations/202610010002_import_origin/migration.sql new file mode 100644 index 0000000..4b6d458 --- /dev/null +++ b/apps/api/prisma/migrations/202610010002_import_origin/migration.sql @@ -0,0 +1,3 @@ +-- Additive migration: retain all existing positions and history. +ALTER TABLE `Position` ADD COLUMN `importedFromId` CHAR(36) NULL; +CREATE UNIQUE INDEX `Position_userId_importedFromId_key` ON `Position`(`userId`, `importedFromId`); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 04adbda..5389820 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -25,6 +25,7 @@ model Session { } 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) kind String @db.VarChar(16) @@ -40,6 +41,7 @@ model Position { outgoing PositionLink[] @relation("Source") incoming PositionLink[] @relation("Target") @@index([userId,kind]) + @@unique([userId,importedFromId]) } model Revision { id String @id @default(uuid()) @db.Char(36) diff --git a/apps/api/scripts/database.cjs b/apps/api/scripts/database.cjs new file mode 100644 index 0000000..614b8f7 --- /dev/null +++ b/apps/api/scripts/database.cjs @@ -0,0 +1,19 @@ +const { spawnSync } = require('node:child_process'); +const command = process.argv[2]; +if (!['deploy', 'status'].includes(command)) process.exit(1); +const p = spawnSync( + process.execPath, + [require.resolve('prisma/build/index.js'), 'migrate', command], + { encoding: 'utf8' }, +); +// Prisma datasource lines can expose local connection metadata; omit them. +const output = (p.stdout || '') + .split(/\r?\n/) + .filter((s) => !s.startsWith('Datasource ') && !s.includes('Environment variables')) + .join('\n'); +if (p.status === 0) console.log(output); +else + console.error( + 'Database migration command failed. Check local database access and migration compatibility. No reset was performed.', + ); +process.exitCode = p.status || 0; diff --git a/apps/api/scripts/db-preflight.cjs b/apps/api/scripts/db-preflight.cjs index d469409..da7d38e 100644 --- a/apps/api/scripts/db-preflight.cjs +++ b/apps/api/scripts/db-preflight.cjs @@ -1,3 +1,40 @@ -require('dotenv').config({quiet:true}); -const mysql=require('mysql2/promise'); -(async()=>{let db;try {const u=new URL(process.env.DATABASE_URL);const name=u.pathname.slice(1);if(!/^[a-zA-Z0-9_]+$/.test(name))throw Error();db=await mysql.createConnection({host:u.hostname,port:Number(u.port||3306),user:decodeURIComponent(u.username),password:decodeURIComponent(u.password)});const [existing]=await db.execute('SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=?',[name]);if(existing.length){const [tables]=await db.execute('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?',[name]);console.log('Database already exists; tables:',tables.map(t=>t.TABLE_NAME));}else {await db.query('CREATE DATABASE `'+name+'` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');console.log('Created new empty project database.');}}catch(e){console.error('Database preflight failed:',e.code||'configuration error');process.exitCode=1;}finally{if(db)await db.end();}})(); +require('dotenv').config({ quiet: true }); +const mysql = require('mysql2/promise'); +(async () => { + let db; + try { + const u = new URL(process.env.DATABASE_URL); + const name = u.pathname.slice(1); + if (!/^[a-zA-Z0-9_]+$/.test(name)) throw Error(); + db = await mysql.createConnection({ + host: u.hostname, + port: Number(u.port || 3306), + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + }); + const [existing] = await db.execute( + 'SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=?', + [name], + ); + if (existing.length) { + const [tables] = await db.execute( + 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=?', + [name], + ); + console.log( + 'Database already exists; tables:', + tables.map((t) => t.TABLE_NAME), + ); + } else { + await db.query( + 'CREATE DATABASE `' + name + '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + ); + console.log('Created new empty project database.'); + } + } catch (e) { + console.error('Database preflight failed:', e.code || 'configuration error'); + process.exitCode = 1; + } finally { + if (db) await db.end(); + } +})(); diff --git a/apps/api/scripts/dev.cjs b/apps/api/scripts/dev.cjs new file mode 100644 index 0000000..7b6a272 --- /dev/null +++ b/apps/api/scripts/dev.cjs @@ -0,0 +1,13 @@ +const { spawn, spawnSync } = require('node:child_process'); +const tsc = require.resolve('typescript/bin/tsc'); +if (spawnSync(process.execPath, [tsc], { stdio: 'inherit' }).status !== 0) process.exit(1); +const children = [ + spawn(process.execPath, [tsc, '--watch', '--preserveWatchOutput'], { stdio: 'inherit' }), + spawn(process.execPath, ['--watch', 'dist/main.js'], { stdio: 'inherit' }), +]; +function stop() { + for (const p of children) p.kill(); + process.exit(); +} +process.on('SIGINT', stop); +process.on('SIGTERM', stop); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts new file mode 100644 index 0000000..2e34045 --- /dev/null +++ b/apps/api/src/auth.ts @@ -0,0 +1,142 @@ +import { + Injectable, + Controller, + Get, + Post, + Body, + Req, + Res, + CanActivate, + ExecutionContext, + UnauthorizedException, + ForbiddenException, + HttpException, + SetMetadata, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { randomBytes, createHash } from 'node:crypto'; +import { hash, compare } from 'bcryptjs'; +import { Database } from './database'; +import { credentials } from './validation'; +export type UserRequest = Request & { userId: string }; +const Public = () => SetMetadata('public', true); +const digest = (s: string) => createHash('sha256').update(s).digest('hex'); +@Injectable() +export class AuthService { + private attempts = new Map(); + constructor(private db: Database) {} + limit(req: Request) { + const key = req.ip || 'local', + now = Date.now(); + let v = this.attempts.get(key); + if (!v || v.until < now) { + v = { count: 0, until: now + 900000 }; + this.attempts.set(key, v); + } + if (++v.count > 30) throw new HttpException('尝试过于频繁,请 15 分钟后重试', 429); + if (this.attempts.size > 10000) { + for (const [k, v] of this.attempts) if (v.until < now) this.attempts.delete(k); + if (this.attempts.size > 10000) throw new HttpException('服务繁忙,请稍后重试', 429); + } + } + async issue(userId: string, res: Response) { + const token = randomBytes(32).toString('hex'), + expiresAt = new Date(Date.now() + 7 * 86400000); + await this.db.session.create({ data: { id: digest(token), userId, expiresAt } }); + res.cookie('wp_session', token, { + httpOnly: true, + sameSite: 'strict', + secure: process.env.COOKIE_SECURE === 'true', + expires: expiresAt, + path: '/api', + }); + } + async user(token: unknown) { + if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) return null; + const s = await this.db.session.findUnique({ where: { id: digest(token) } }); + return s && s.expiresAt > new Date() ? s.userId : null; + } + async logout(req: Request, res: Response) { + if (typeof req.cookies?.wp_session === 'string') + await this.db.session.deleteMany({ where: { id: digest(req.cookies.wp_session) } }); + res.clearCookie('wp_session', { + path: '/api', + sameSite: 'strict', + secure: process.env.COOKIE_SECURE === 'true', + httpOnly: true, + }); + } +} +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private auth: AuthService, + private reflector: Reflector, + ) {} + async canActivate(ctx: ExecutionContext) { + const req = ctx.switchToHttp().getRequest(); + if ( + !['GET', 'HEAD', 'OPTIONS'].includes(req.method) && + req.headers.origin !== process.env.WEB_ORIGIN + ) + throw new ForbiddenException('请求来源不受信任'); + if (this.reflector.get('public', ctx.getHandler())) return true; + const id = await this.auth.user(req.cookies?.wp_session); + if (!id) throw new UnauthorizedException('请先登录'); + req.userId = id; + return true; + } +} +@Controller('api') +export class AuthController { + constructor( + private db: Database, + private auth: AuthService, + ) {} + @Public() @Get('health') health() { + return { status: 'ok' }; + } + @Public() @Post('auth/register') async register( + @Body() body: unknown, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + this.auth.limit(req); + const v = credentials.parse(body), + user = await this.db.user.create({ + data: { username: v.username, passwordHash: await hash(v.password, 12) }, + }); + await this.auth.issue(user.id, res); + return { username: user.username, baseCurrency: user.baseCurrency }; + } + @Public() @Post('auth/login') async login( + @Body() body: unknown, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + this.auth.limit(req); + const v = credentials.parse(body), + user = await this.db.user.findUnique({ where: { username: v.username } }); + const ok = await compare( + v.password, + user?.passwordHash || '$2b$12$JZKvzAzfqM3obKxMQTFBjOzqIIKG97kn96/xUMbgTuWUnlMAT2bSi', + ); + if (!user || !ok) throw new UnauthorizedException('账号或密码错误'); + await this.auth.issue(user.id, res); + return { username: user.username, baseCurrency: user.baseCurrency }; + } + @Get('auth/me') async me(@Req() req: UserRequest) { + return this.db.user.findUniqueOrThrow({ + where: { id: req.userId }, + select: { username: true, baseCurrency: true }, + }); + } + @Post('auth/logout') async logout( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + await this.auth.logout(req, res); + return { ok: true }; + } +} diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts new file mode 100644 index 0000000..8100cff --- /dev/null +++ b/apps/api/src/backup.ts @@ -0,0 +1,290 @@ +import { + Controller, + Get, + Post, + Body, + Req, + Res, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { Response } from 'express'; +import { z } from 'zod'; +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 { day } from './calculation'; +const timestamp = z.iso + .datetime() + .refine( + (s) => s >= '1900-01-01T00:00:00.000Z' && new Date(s).getTime() <= Date.now() + 60000, + '创建和更新时间无效', + ); +const record = positionMeta + .extend({ + kind: z.enum(['account', 'asset', 'debt']), + side: z.enum(['asset', 'liability']), + currency, + id: z.string().uuid(), + importedFromId: z.string().uuid().nullable().optional(), + createdAt: timestamp, + updatedAt: timestamp, + revisions: z + .array( + revisionInput.extend({ + id: z.string().uuid(), + createdAt: timestamp, + updatedAt: timestamp, + }), + ) + .min(1) + .max(10000), + }) + .strict(); +const backupSchema = z + .object({ + format: z.literal('worthpath'), + version: z.literal(1), + exportedAt: z.iso.datetime(), + baseCurrency: currency, + currencies: z.array(currency).max(10), + positions: z.array(record).max(1000), + links: z + .array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()) + .max(20000), + rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })).max(20000), + }) + .strict(); +type Backup = z.infer; +export function validateBackup(raw: unknown) { + const b = backupSchema.parse(raw), + ids = new Map(b.positions.map((p) => [p.id, p])); + if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID'); + if (b.positions.reduce((n, p) => n + p.revisions.length, 0) > 20000) + throw new BadRequestException('单次备份最多 20000 条历史'); + const origins = b.positions.map((p) => p.importedFromId || p.id); + if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目'); + const revisionIds = new Set(); + for (const p of b.positions) { + positionInput.parse({ + name: p.name, + category: p.category, + kind: p.kind, + side: p.side, + currency: p.currency, + notes: p.notes, + archived: p.archived, + amount: '0', + date: p.revisions[0].date, + }); + const dates = new Set(); + for (const r of p.revisions) { + if (dates.has(r.date) || revisionIds.has(r.id)) throw new BadRequestException('重复历史记录'); + dates.add(r.date); + revisionIds.add(r.id); + } + if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整'); + } + const links = new Set(); + for (const l of b.links) { + const s = ids.get(l.sourceId), + t = ids.get(l.targetId), + key = l.sourceId + l.targetId; + if (!s || !t || s.kind !== 'debt' || t.kind === 'debt' || s.id === t.id || links.has(key)) + throw new BadRequestException('关联关系无效'); + links.add(key); + } + const rates = new Set(); + for (const r of b.rates) { + const key = `${r.currency}/${r.baseCurrency}/${r.date}`; + if (rates.has(key)) throw new BadRequestException('重复汇率'); + rates.add(key); + if (!b.currencies.includes(r.currency) || !b.currencies.includes(r.baseCurrency)) + throw new BadRequestException('币种清单不完整'); + } + if (!b.currencies.includes(b.baseCurrency)) throw new BadRequestException('本位币清单不完整'); + return b; +} +@Controller('api/backup') +export class BackupController { + constructor(private db: Database) {} + private async data(userId: string): Promise { + const [user, ps, rates] = await this.db.$transaction([ + this.db.user.findUniqueOrThrow({ + where: { id: userId }, + select: { baseCurrency: true }, + }), + this.db.position.findMany({ + where: { userId }, + include: { revisions: true, outgoing: true }, + }), + this.db.exchangeRate.findMany({ where: { userId } }), + ]); + const positions = ps.map((p) => ({ + id: p.id, + importedFromId: p.importedFromId, + name: p.name, + kind: p.kind, + side: p.side, + category: p.category, + currency: p.currency, + notes: p.notes, + archived: p.archived, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + revisions: p.revisions.map((r) => ({ + id: r.id, + amount: r.amount.toString(), + date: day(r.effectiveDate), + notes: r.notes, + reason: r.reason, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + })), + })); + return backupSchema.parse({ + format: 'worthpath', + version: 1, + exportedAt: new Date().toISOString(), + baseCurrency: user.baseCurrency, + currencies: [ + ...new Set([ + user.baseCurrency, + ...ps.map((p) => p.currency), + ...rates.flatMap((r) => [r.currency, r.baseCurrency]), + ]), + ], + positions, + links: ps.flatMap((p) => + p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })), + ), + rates: rates.map((r) => ({ + currency: r.currency, + baseCurrency: r.baseCurrency, + date: day(r.date), + rate: r.rate.toString(), + source: r.source, + })), + }); + } + @Get() async download(@Req() r: UserRequest, @Res() res: Response) { + const b = await this.data(r.userId); + res.setHeader( + 'Content-Disposition', + `attachment; filename="worthpath-${b.exportedAt.slice(0, 10)}.json"`, + ); + res.setHeader('Cache-Control', 'no-store'); + res.type('application/json').send(JSON.stringify(b, null, 2)); + } + @Post('preview') async preview(@Req() r: UserRequest, @Body() raw: unknown) { + const b = validateBackup(raw), + existing = await this.data(r.userId); + this.conflicts(b, existing); + return { + positions: b.positions.length, + revisions: b.positions.reduce((n, p) => n + p.revisions.length, 0), + rates: b.rates.length, + baseCurrency: b.baseCurrency, + currentBaseCurrency: existing.baseCurrency, + message: + '只追加新项目并重建关联,相同汇率保留。不会覆盖已有项目。已有本位币保留,空账户恢复备份本位币。确认后以事务导入。', + }; + } + private conflicts(b: Backup, existing: Backup) { + const ids = new Set( + existing.positions.flatMap((p) => [p.id, p.importedFromId].filter(Boolean)), + ); + if (b.positions.some((p) => ids.has(p.id) || ids.has(p.importedFromId || p.id))) + throw new ConflictException('包含已有或重复项目,请勿重复导入;首版只支持追加新项目'); + for (const rate of b.rates) { + const e = existing.rates.find( + (r) => + r.currency === rate.currency && + r.baseCurrency === rate.baseCurrency && + r.date === rate.date, + ); + if (e && !new Decimal(e.rate).eq(rate.rate)) + throw new ConflictException('已有同日汇率与备份冲突,未修改数据'); + } + } + @Post('import') async restore(@Req() r: UserRequest, @Body() raw: unknown) { + const { backup } = z + .object({ confirmed: z.literal(true), backup: backupSchema }) + .strict() + .parse(raw), + b = validateBackup(backup); + return this.db.$transaction( + async (tx) => { + const ps = await tx.position.findMany({ + where: { userId: r.userId }, + include: { revisions: true }, + }), + rs = await tx.exchangeRate.findMany({ where: { userId: r.userId } }); + const existing = { + positions: ps.map((p) => ({ + ...p, + revisions: p.revisions.map((v) => ({ + ...v, + amount: v.amount.toString(), + date: day(v.effectiveDate), + })), + })), + rates: rs.map((v) => ({ ...v, rate: v.rate.toString(), date: day(v.date) })), + } as unknown as Backup; + this.conflicts(b, existing); + const mapping = new Map(); + for (const p of b.positions) { + const row = await tx.position.create({ + data: { + userId: r.userId, + importedFromId: p.importedFromId || p.id, + name: p.name, + kind: p.kind, + side: p.side, + category: p.category, + currency: p.currency, + notes: p.notes, + archived: p.archived, + createdAt: new Date(p.createdAt), + updatedAt: new Date(p.updatedAt), + revisions: { + create: p.revisions.map((v) => ({ + amount: v.amount, + effectiveDate: new Date(v.date), + notes: v.notes, + reason: v.reason, + createdAt: new Date(v.createdAt), + updatedAt: new Date(v.updatedAt), + })), + }, + }, + }); + mapping.set(p.id, row.id); + } + for (const l of b.links) + await tx.positionLink.create({ + data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! }, + }); + for (const v of b.rates) { + const key = { + userId: r.userId, + currency: v.currency, + baseCurrency: v.baseCurrency, + date: new Date(v.date), + }; + await tx.exchangeRate.upsert({ + where: { userId_currency_baseCurrency_date: key }, + create: { ...key, rate: v.rate, source: v.source }, + update: {}, + }); + } + if (!ps.length && !rs.length) + await tx.user.update({ where: { id: r.userId }, data: { baseCurrency: b.baseCurrency } }); + return { ok: true, positions: b.positions.length }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 30000 }, + ); + } +} diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts new file mode 100644 index 0000000..7452c7f --- /dev/null +++ b/apps/api/src/calculation.ts @@ -0,0 +1,136 @@ +import Decimal from 'decimal.js'; +Decimal.set({ precision: 50, rounding: Decimal.ROUND_HALF_UP }); +export type Holding = { + id: string; + name: string; + kind: string; + side: string; + currency: string; + revisions: { + id: string; + amount: { toString(): string }; + effectiveDate: Date; + notes: string; + reason: string; + }[]; +}; +export type Rate = { + currency: string; + baseCurrency: string; + date: Date; + rate: { toString(): string }; + source: string; +}; +export const day = (d: Date) => d.toISOString().slice(0, 10); +export function history(p: Holding) { + let before = new Decimal(0); + return [...p.revisions] + .sort((a, b) => +a.effectiveDate - +b.effectiveDate) + .map((r) => { + const after = new Decimal(r.amount.toString()); + const row = { + id: r.id, + positionId: p.id, + name: p.name, + kind: p.kind, + currency: p.currency, + date: day(r.effectiveDate), + before: before.toFixed(), + after: after.toFixed(), + delta: after.minus(before).toFixed(), + notes: r.notes, + reason: r.reason, + }; + before = after; + return row; + }); +} +export function rateAt(rates: Rate[], currency: string, base: string, date: string) { + if (currency === base) return { value: new Decimal(1), date, source: 'identity' }; + const r = rates + .filter((r) => r.currency === currency && r.baseCurrency === base && day(r.date) <= date) + .sort((a, b) => +b.date - +a.date)[0]; + return r ? { value: new Decimal(r.rate.toString()), date: day(r.date), source: r.source } : null; +} +export function totals(positions: Holding[], rates: Rate[], base: string, date: string) { + let assets = new Decimal(0), + liabilities = new Decimal(0); + const missing = new Set(); + const items = positions.map((p) => { + const rev = p.revisions + .filter((r) => day(r.effectiveDate) <= date) + .sort((a, b) => +b.effectiveDate - +a.effectiveDate)[0], + amount = new Decimal(rev?.amount.toString() || '0'), + fx = rateAt(rates, p.currency, base, date); + if (!fx && !amount.isZero()) missing.add(p.currency); + const converted = fx ? amount.mul(fx.value) : null; + if (converted) { + if (p.side === 'asset') assets = assets.plus(converted); + else liabilities = liabilities.plus(converted); + } + return { + id: p.id, + name: p.name, + kind: p.kind, + side: p.side, + currency: p.currency, + amount: amount.toFixed(), + converted: converted?.toFixed(2) ?? null, + rateDate: fx?.date ?? null, + source: fx?.source ?? null, + }; + }); + return { + date, + assets: assets.toFixed(2), + liabilities: liabilities.toFixed(2), + net: assets.minus(liabilities).toFixed(2), + complete: missing.size === 0, + missing: [...missing], + items, + }; +} +export function overview(positions: Holding[], rates: Rate[], base: string, date: string) { + const dates = [ + ...new Set([ + ...positions.flatMap((p) => p.revisions.map((r) => day(r.effectiveDate))), + ...rates.filter((r) => r.baseCurrency === base).map((r) => day(r.date)), + date, + ]), + ] + .filter((d) => d <= date) + .sort(); + let previous: ReturnType | undefined; + const trend = dates.map((d) => { + const value = totals(positions, rates, base, d); + let balanceChange: string | null = null, + fxChange: string | null = null; + if (previous?.complete && value.complete) { + let revalued = new Decimal(0); + for (const item of previous.items) { + const fx = rateAt(rates, item.currency, base, d); + if (!fx && !new Decimal(item.amount).isZero()) { + revalued = new Decimal(NaN); + break; + } + const v = new Decimal(item.amount).mul(fx?.value || 0); + revalued = revalued.plus(item.side === 'asset' ? v : v.neg()); + } + if (revalued.isFinite()) { + fxChange = revalued.minus(previous.net).toFixed(2); + balanceChange = new Decimal(value.net).minus(revalued).toFixed(2); + } + } + previous = value; + return { ...value, balanceChange, fxChange }; + }); + return { + baseCurrency: base, + ...totals(positions, rates, base, date), + trend, + recent: positions + .flatMap(history) + .sort((a, b) => b.date.localeCompare(a.date)) + .slice(0, 20), + }; +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts new file mode 100644 index 0000000..5557234 --- /dev/null +++ b/apps/api/src/database.ts @@ -0,0 +1,11 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +@Injectable() +export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9d8b05a..f895031 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,69 @@ import 'reflect-metadata'; import 'dotenv/config'; -import {Module,Controller,Get} from '@nestjs/common'; -import {NestFactory} from '@nestjs/core'; -import {PrismaClient} from '@prisma/client'; -@Controller('api') class HealthController {@Get('health') health(){return {status:'ok',app:'WorthPath'};}} -@Module({controllers:[HealthController]}) class AppModule {} -async function bootstrap(){ const db=new PrismaClient();await db.$connect(); const app=await NestFactory.create(AppModule);await app.listen(Number(process.env.PORT||3000),'0.0.0.0');}void bootstrap(); +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 { PortfolioController } from './portfolio'; +import { BackupController } from './backup'; +import { Database } from './database'; +import { RatesService, SettingsController } from './rates'; +import { ZodError } from 'zod'; +import { Prisma } from '@prisma/client'; +@Catch() +class SafeErrors implements ExceptionFilter { + catch(error: unknown, host: ArgumentsHost) { + let status = 500, + message = '服务暂时不可用,请稍后重试'; + if (error instanceof ZodError) { + status = 400; + message = error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(';'); + } else if (error instanceof HttpException) { + status = error.getStatus(); + message = error.message; + } else if ( + error instanceof Prisma.PrismaClientKnownRequestError && + ['P2002', 'P2034'].includes(error.code) + ) { + status = 409; + message = '数据已存在或已被其他操作更新,请刷新后重试'; + } else if (error instanceof SyntaxError) { + status = 400; + message = 'JSON 文件或请求格式无效'; + } else if ((error as { status?: number })?.status === 413) { + status = 413; + message = '文件不能超过 8 MB'; + } + host.switchToHttp().getResponse().setHeader('Cache-Control', 'no-store'); + host.switchToHttp().getResponse().status(status).json({ message }); + } +} +@Module({ + providers: [Database, AuthService, RatesService, { provide: APP_GUARD, useClass: AuthGuard }], + controllers: [AuthController, PortfolioController, SettingsController, BackupController], +}) +class AppModule {} +async function bootstrap() { + if (!process.env.DATABASE_URL || !process.env.WEB_ORIGIN) + throw Error('Missing local environment configuration'); + if (process.env.NODE_ENV === 'production' && process.env.COOKIE_SECURE !== 'true') + throw Error('Production requires secure cookies'); + const app = await NestFactory.create(AppModule, { logger: false, bodyParser: false }); + app.use(helmet()); + app.use(json({ limit: '8mb' })); + app.use(cookieParser()); + app.use((_req: unknown, res: { setHeader: (k: string, v: string) => void }, next: () => void) => { + res.setHeader('Cache-Control', 'no-store'); + next(); + }); + app.useGlobalFilters(new SafeErrors()); + app.enableShutdownHooks(); + await app.listen(Number(process.env.PORT || 3100), '0.0.0.0'); + console.log('WorthPath API ready'); +} +void bootstrap().catch(() => { + console.error('API startup failed. Check local configuration and database availability.'); + process.exitCode = 1; +}); diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts new file mode 100644 index 0000000..9a8d5de --- /dev/null +++ b/apps/api/src/portfolio.ts @@ -0,0 +1,175 @@ +import { + Controller, + Get, + Post, + Patch, + Put, + Body, + Req, + Param, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { Database } from './database'; +import { UserRequest } from './auth'; +import { positionInput, positionMeta, revisionInput, today } from './validation'; +import { history, overview } from './calculation'; +import { z } from 'zod'; +import { Prisma } from '@prisma/client'; +import { RatesService } from './rates'; +@Controller('api') +export class PortfolioController { + constructor( + private db: Database, + private fx: RatesService, + ) {} + private async own(userId: string, id: string) { + const p = await this.db.position.findFirst({ + where: { id, userId }, + include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true }, + }); + 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 }, + include: { revisions: { orderBy: { effectiveDate: 'asc' } }, outgoing: true }, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((p) => ({ + ...p, + userId: undefined, + amount: p.revisions.at(-1)?.amount.toString() || '0', + history: history(p), + })); + } + @Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) { + const p = await this.own(r.userId, id); + return { ...p, userId: undefined, history: history(p) }; + } + @Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) { + const v = positionInput.parse(b), + { amount, date, ...meta } = v; + const created = await this.db.position.create({ + data: { + ...meta, + userId: r.userId, + revisions: { + create: { amount, effectiveDate: new Date(date), notes: v.notes, reason: 'initial' }, + }, + }, + select: { id: true }, + }); + this.fx.invalidate(r.userId); + return created; + } + @Patch('positions/:id') async edit( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + const v = positionMeta.parse(b), + p = await this.own(r.userId, id); + if ( + p.kind === 'account' && + ['credit_card', 'loan'].includes(v.category) && + p.side !== 'liability' + ) + throw new BadRequestException('信用卡和贷款账户必须为负债'); + await this.db.position.update({ where: { id: p.id }, data: v }); + return { ok: true }; + } + @Post('positions/:id/revisions') async revise( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + const v = revisionInput.parse(b); + return this.db.$transaction( + async (tx) => { + const p = await tx.position.findFirst({ where: { id, userId: r.userId } }); + if (!p) throw new NotFoundException('项目不存在'); + if (p.archived) throw new ConflictException('请先恢复归档项目'); + return tx.revision.create({ + data: { + positionId: p.id, + amount: v.amount, + effectiveDate: new Date(v.date), + notes: v.notes, + reason: v.reason, + }, + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } + @Put('positions/:id/revisions/:revisionId') async correct( + @Req() r: UserRequest, + @Param('id') id: string, + @Param('revisionId') revisionId: string, + @Body() b: unknown, + ) { + const v = revisionInput.parse(b), + p = await this.own(r.userId, id); + 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: new Date(v.date), + notes: v.notes, + reason: 'correction', + }, + }); + return { ok: true }; + } + @Put('positions/:id/links') async link( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + const { targetIds } = z + .object({ targetIds: z.array(z.string().uuid()).max(20) }) + .strict() + .parse(b); + if (new Set(targetIds).size !== targetIds.length || targetIds.includes(id)) + throw new BadRequestException('关联不能重复或指向自身'); + return this.db.$transaction( + async (tx) => { + const source = await tx.position.findFirst({ + where: { id, userId: r.userId, kind: 'debt' }, + }); + if (!source) throw new NotFoundException('债务不存在'); + const count = await tx.position.count({ + where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } }, + }); + if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产'); + await tx.positionLink.deleteMany({ where: { sourceId: id } }); + await tx.positionLink.createMany({ + data: targetIds.map((targetId) => ({ sourceId: id, targetId })), + }); + return { ok: true }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + } + @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({ + where: { id: r.userId }, + select: { baseCurrency: true }, + }), + this.db.position.findMany({ + where: { userId: r.userId }, + include: { revisions: true }, + }), + this.db.exchangeRate.findMany({ where: { userId: r.userId } }), + ]); + return overview(positions, rates, user.baseCurrency, today()); + } +} diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts new file mode 100644 index 0000000..c6d3908 --- /dev/null +++ b/apps/api/src/rates.ts @@ -0,0 +1,192 @@ +import { + Injectable, + Controller, + Get, + Patch, + Post, + Put, + Req, + Body, + OnModuleInit, + OnModuleDestroy, + BadGatewayException, +} from '@nestjs/common'; +import { Database } from './database'; +import { UserRequest } from './auth'; +import { currency, rateInput, date, rateValue, today } from './validation'; +import { z } from 'zod'; +import Decimal from 'decimal.js'; +// Fixed public request; no user currency choices, identifiers or amounts leave the server. +const PUBLIC_RATES = + 'https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD'; +@Injectable() +export class RatesService implements OnModuleInit, OnModuleDestroy { + private timer?: NodeJS.Timeout; + private attempts = new Map(); + private running = new Set(); + private outcomes = new Map(); + status(userId: string) { + return ( + this.outcomes.get(userId) || { state: 'idle', attemptedAt: null, message: '尚未尝试更新' } + ); + } + invalidate(userId: string) { + this.attempts.delete(userId); + } + constructor(private db: Database) {} + onModuleInit() { + this.timer = setInterval(() => { + void this.tick(); + }, 3600000); + this.timer.unref(); + } + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + private async tick() { + try { + for (const u of await this.db.user.findMany({ select: { id: true } })) await this.daily(u.id); + } catch { + /* Keep previous rates. */ + } + } + async daily(userId: string) { + if (this.attempts.get(userId) === today() || this.running.has(userId)) return; + this.attempts.set(userId, today()); + try { + await this.refresh(userId); + } catch { + /* UI shows missing/stale rates. */ + } + } + async refresh(userId: string) { + if (this.running.has(userId)) return { message: '汇率更新正在进行' }; + this.running.add(userId); + this.outcomes.set(userId, { + state: 'updating', + attemptedAt: new Date().toISOString(), + message: '正在更新公共日汇率', + }); + try { + const u = await this.db.user.findUniqueOrThrow({ where: { id: userId } }); + const ps = await this.db.position.findMany({ + where: { userId }, + select: { currency: true }, + distinct: ['currency'], + }); + if (!ps.some((p) => p.currency !== u.baseCurrency)) { + const message = '当前没有需要换算的外币项目'; + this.outcomes.set(userId, { state: 'ok', attemptedAt: new Date().toISOString(), message }); + return { message }; + } + const response = await fetch(PUBLIC_RATES, { signal: AbortSignal.timeout(12000) }); + if (!response.ok) throw Error(); + const raw = (await response.text()).replace( + /("rate"\s*:\s*)(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + '$1"$2"', + ); + const rows = z + .array( + z.object({ + base: z.literal('USD'), + quote: currency, + date, + rate: z.string().refine((s) => new Decimal(s).gt(0)), + }), + ) + .parse(JSON.parse(raw)); + const data = ps + .filter((p) => p.currency !== u.baseCurrency) + .map((p) => { + const from = p.currency === 'USD' ? null : rows.find((v) => v.quote === p.currency), + to = u.baseCurrency === 'USD' ? null : rows.find((v) => v.quote === u.baseCurrency); + if ((p.currency !== 'USD' && !from) || (u.baseCurrency !== 'USD' && !to)) throw Error(); + if (from && to && from.date !== to.date) throw Error(); + const rate = rateValue.parse( + new Decimal(to?.rate || '1').div(from?.rate || '1').toFixed(12), + ), + effectiveDate = from?.date || to!.date; + return { + userId, + currency: p.currency, + baseCurrency: u.baseCurrency, + date: new Date(effectiveDate), + rate, + source: 'frankfurter', + }; + }); + await this.db.$transaction(async (tx) => { + for (const v of data) { + const { rate, source, ...key } = v; + const existing = await tx.exchangeRate.findUnique({ + where: { userId_currency_baseCurrency_date: key }, + }); + if (existing?.source === 'manual') continue; + await tx.exchangeRate.upsert({ + where: { userId_currency_baseCurrency_date: key }, + create: v, + update: { rate, source }, + }); + } + }); + const message = '已保存最新可用日汇率;休市日可能沿用上一工作日。同日手动汇率已保留。'; + this.outcomes.set(userId, { state: 'ok', attemptedAt: new Date().toISOString(), message }); + return { message }; + } catch { + this.outcomes.set(userId, { + state: 'error', + attemptedAt: new Date().toISOString(), + message: '自动汇率更新失败,原币和已有汇率已保留,请重试或手动录入', + }); + throw new BadGatewayException('汇率更新失败,原币金额和已有汇率已保留;可稍后重试或手动录入'); + } finally { + this.running.delete(userId); + } + } +} +@Controller('api') +export class SettingsController { + constructor( + private db: Database, + private fx: RatesService, + ) {} + @Get('settings') async settings(@Req() r: UserRequest) { + const u = await this.db.user.findUniqueOrThrow({ + where: { id: r.userId }, + select: { username: true, baseCurrency: true }, + }); + return { + ...u, + fxStatus: this.fx.status(r.userId), + rates: await this.db.exchangeRate.findMany({ + where: { userId: r.userId }, + select: { currency: true, baseCurrency: true, date: true, rate: true, source: true }, + orderBy: { date: 'desc' }, + }), + }; + } + @Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) { + const { baseCurrency } = z.object({ baseCurrency: currency }).strict().parse(b); + await this.db.user.update({ where: { id: r.userId }, data: { baseCurrency } }); + this.fx.invalidate(r.userId); + return { ok: true }; + } + @Put('rates') async manual(@Req() r: UserRequest, @Body() b: unknown) { + const v = rateInput.parse(b), + key = { + userId: r.userId, + currency: v.currency, + baseCurrency: v.baseCurrency, + date: new Date(v.date), + }; + await this.db.exchangeRate.upsert({ + where: { userId_currency_baseCurrency_date: key }, + create: { ...key, rate: v.rate, source: 'manual' }, + update: { rate: v.rate, source: 'manual' }, + }); + return { ok: true }; + } + @Post('rates/refresh') async refresh(@Req() r: UserRequest) { + return this.fx.refresh(r.userId); + } +} diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts new file mode 100644 index 0000000..2b604e2 --- /dev/null +++ b/apps/api/src/validation.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +export const currencies = [ + 'CNY', + 'USD', + 'HKD', + 'EUR', + 'GBP', + 'JPY', + 'AUD', + 'CAD', + 'CHF', + 'SGD', +] as const; +export const currency = z.enum(currencies); +export function today() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Hong_Kong', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); +} +export const date = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((s) => { + const d = new Date(s + 'T00:00:00Z'); + return ( + Number.isFinite(+d) && d.toISOString().slice(0, 10) === s && s >= '1900-01-01' && s <= today() + ); + }, '日期无效或在未来'); +export const amount = z + .string() + .regex(/^(0|[1-9]\d{0,15})(\.\d{1,8})?$/, '金额须为非负十进制字符串,最多 16 位整数和 8 位小数'); +export const rateValue = z + .string() + .regex(/^(0|[1-9]\d{0,11})(\.\d{1,12})?$/) + .refine((s) => /[1-9]/.test(s), '汇率必须大于零'); +export const notes = z.string().max(2000).default(''); +export const revisionInput = z + .object({ + amount, + date, + notes, + reason: z + .enum(['initial', 'balance', 'valuation', 'repayment', 'correction']) + .default('balance'), + }) + .strict(); +export const positionMeta = z + .object({ + name: z.string().trim().min(1).max(100), + category: z.string().trim().min(1).max(40), + notes, + archived: z.boolean().default(false), + }) + .strict(); +export const positionInput = positionMeta + .extend({ + kind: z.enum(['account', 'asset', 'debt']), + side: z.enum(['asset', 'liability']), + currency, + amount, + date, + }) + .strict() + .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') + ) + c.addIssue({ code: 'custom', message: '项目类型与资产负债属性不符' }); + }); +export const rateInput = z + .object({ currency, baseCurrency: currency, date, rate: rateValue }) + .strict() + .refine((r) => r.currency !== r.baseCurrency, '同币种无需汇率'); +export const credentials = z + .object({ + username: z + .string() + .trim() + .min(3) + .max(64) + .regex(/^[\p{L}\p{N}_@.\-]+$/u), + password: z + .string() + .min(10) + .max(72) + .refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'), + }) + .strict(); diff --git a/apps/api/test/calculation.test.ts b/apps/api/test/calculation.test.ts new file mode 100644 index 0000000..7011120 --- /dev/null +++ b/apps/api/test/calculation.test.ts @@ -0,0 +1,136 @@ +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 { positionInput, date, amount } from '../src/validation'; +import { validateBackup } from '../src/backup'; +import { RatesService } from '../src/rates'; +import { Database } from '../src/database'; +const rev = (amount: string, day: string) => ({ + id: randomUUID(), + amount, + effectiveDate: new Date(day), + notes: '', + reason: 'balance', +}); +const p = (side = 'asset', currency = 'CNY'): Holding => ({ + id: randomUUID(), + name: 'test', + kind: 'account', + side, + currency, + revisions: [rev('100.1', '2026-09-01')], +}); +const rate = (value: string, day: string): Rate => ({ + currency: 'USD', + baseCurrency: 'CNY', + date: new Date(day), + rate: value, + source: 'manual', +}); +test('decimal totals and liability sign', () => { + const a = p(), + b = p('liability'); + b.revisions[0].amount = '0.2'; + assert.equal(totals([a, b], [], 'CNY', '2026-09-01').net, '99.90'); +}); +test('missing FX explicitly incomplete', () => { + const v = totals([p('asset', 'USD')], [], 'CNY', '2026-09-01'); + assert.equal(v.complete, false); + assert.deepEqual(v.missing, ['USD']); + assert.equal(v.items[0].converted, null); +}); +test('correction recalculates later delta', () => { + const a = p(); + a.revisions = [rev('90.1', '2026-09-01'), rev('110.1', '2026-09-02')]; + assert.equal(history(a)[1].delta, '20'); + assert.equal(totals([a], [], 'CNY', '2026-09-02').net, '110.10'); +}); +test('FX and actual changes separated', () => { + const a = p('asset', 'USD'); + a.revisions = [rev('100', '2026-09-01'), rev('110', '2026-09-02')]; + const o = overview([a], [rate('7', '2026-09-01'), rate('8', '2026-09-02')], 'CNY', '2026-09-02'); + assert.equal(o.trend[1].fxChange, '100.00'); + assert.equal(o.trend[1].balanceChange, '80.00'); + assert.equal(o.net, '880.00'); +}); +test('large monetary strings stay exact', () => { + const a = p(); + a.revisions = [rev('9999999999999999.98765432', '2026-09-01')]; + assert.equal(history(a)[0].after, '9999999999999999.98765432'); + assert.equal(totals([a], [], 'CNY', '2026-09-01').net, '9999999999999999.99'); +}); +test('reject invalid dates, negative values and credit card assets', () => { + assert.equal(date.safeParse('2026-02-30').success, false); + assert.equal(amount.safeParse('-1').success, false); + assert.equal( + positionInput.safeParse({ + name: 'Card', + kind: 'account', + side: 'asset', + category: 'credit_card', + currency: 'CNY', + amount: '1', + date: '2026-09-01', + }).success, + false, + ); +}); +test('backup rejects auth data and broken relations', () => { + const b = { + format: 'worthpath', + version: 1, + exportedAt: new Date().toISOString(), + baseCurrency: 'CNY', + currencies: ['CNY'], + positions: [], + rates: [], + links: [], + }; + assert.doesNotThrow(() => validateBackup(b)); + assert.throws(() => validateBackup({ ...b, passwordHash: 'forbidden' })); + assert.throws(() => + validateBackup({ ...b, links: [{ sourceId: randomUUID(), targetId: randomUUID() }] }), + ); +}); +test('public FX uses a fixed request, preserves decimal tokens, manual rates and failure fallback', async () => { + const writes: any[] = []; + let manual = false; + const db = { + user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) }, + position: { findMany: async () => [{ currency: 'USD' }] }, + $transaction: async (fn: any) => + fn({ + exchangeRate: { + findUnique: async () => (manual ? { source: 'manual' } : null), + upsert: async (v: any) => writes.push(v.create), + }, + }), + } as unknown as Database; + const fx = new RatesService(db), + original = globalThis.fetch; + try { + globalThis.fetch = async (url) => { + assert.equal( + String(url), + 'https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD', + ); + return new Response( + '[{"base":"USD","quote":"CNY","date":"2026-09-01","rate":7.987654321098}]', + ); + }; + await fx.refresh('test-owner'); + assert.equal(writes[0].rate, '7.987654321098'); + manual = true; + await fx.refresh('test-owner'); + assert.equal(writes.length, 1); + globalThis.fetch = async () => { + throw Error('offline'); + }; + await assert.rejects(() => fx.refresh('test-owner'), /原币金额和已有汇率已保留/); + assert.equal(writes.length, 1); + assert.equal(fx.status('test-owner').state, 'error'); + } finally { + globalThis.fetch = original; + } +}); diff --git a/apps/api/test/integration.test.ts b/apps/api/test/integration.test.ts new file mode 100644 index 0000000..aaafce7 --- /dev/null +++ b/apps/api/test/integration.test.ts @@ -0,0 +1,319 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { today } from '../src/validation'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api', + origin = process.env.WEB_ORIGIN!; +test('real MySQL: authentication, isolation, history, backup and atomic failures', async () => { + const db = new PrismaClient(), + created: { id: string; username: string }[] = []; + async function call(path: string, method = 'GET', body?: unknown, cookie = '') { + const res = await fetch(base + path, { + method, + headers: { + Origin: origin, + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + return { + status: res.status, + data: await res.json(), + cookie: res.headers.get('set-cookie')?.split(';')[0] || '', + }; + } + async function account() { + const username = 'wp_test_' + randomUUID().slice(0, 12), + password = randomBytes(18).toString('hex'); + const r = await call('/auth/register', 'POST', { username, password }); + assert.equal(r.status, 201); + assert.ok(r.cookie); + const u = await db.user.findUniqueOrThrow({ where: { username } }); + created.push({ id: u.id, username }); + assert.notEqual(u.passwordHash, password); + assert.equal('passwordHash' in r.data, false); + return { ...r, password, username }; + } + try { + assert.equal((await call('/positions')).status, 401); + const a = await account(), + b = await account(); + assert.equal( + ( + await fetch(base + '/settings', { + method: 'PATCH', + headers: { + Cookie: a.cookie, + 'Content-Type': 'application/json', + Origin: 'https://untrusted.invalid', + }, + body: JSON.stringify({ baseCurrency: 'USD' }), + }) + ).status, + 403, + ); + const make = async ( + kind: string, + side: string, + currency: string, + value: string, + category = 'other', + ) => { + const r = await call( + '/positions', + 'POST', + { + name: kind + randomUUID().slice(0, 5), + kind, + side, + currency, + amount: value, + category, + date: '2026-09-01', + notes: '', + }, + a.cookie, + ); + assert.equal(r.status, 201); + return r.data.id as string; + }; + const bank = await make('account', 'asset', 'CNY', '100.10'), + asset = await make('asset', 'asset', 'USD', '100'), + debt = await make('debt', 'liability', 'CNY', '200'), + card = await make('account', 'liability', 'CNY', '50', 'credit_card'); + assert.equal((await call('/positions/' + bank, 'GET', undefined, b.cookie)).status, 404); + assert.equal( + ( + await call( + '/positions/' + bank, + 'PATCH', + { name: 'hack', category: 'other', notes: '', archived: false }, + b.cookie, + ) + ).status, + 404, + ); + assert.equal( + ( + await call( + '/positions/' + bank + '/revisions', + 'POST', + { amount: '1', date: '2026-09-02' }, + b.cookie, + ) + ).status, + 404, + ); + assert.deepEqual((await call('/positions', 'GET', undefined, b.cookie)).data, []); + assert.equal( + ( + await call( + '/positions', + 'POST', + { + name: 'hack', + kind: 'asset', + side: 'asset', + currency: 'CNY', + category: 'other', + amount: '1', + date: '2026-09-01', + userId: created[0].id, + }, + b.cookie, + ) + ).status, + 400, + ); + assert.equal( + ( + await call( + '/rates', + 'PUT', + { currency: 'USD', baseCurrency: 'CNY', rate: '7', date: '2026-09-01' }, + a.cookie, + ) + ).status, + 200, + ); + assert.equal( + ( + await call( + '/rates', + 'PUT', + { currency: 'USD', baseCurrency: 'CNY', rate: '7', date: today() }, + a.cookie, + ) + ).status, + 200, + ); + let o = (await call('/overview', 'GET', undefined, a.cookie)).data; + assert.equal(o.complete, true); + assert.equal(o.net, '550.10'); + assert.equal( + (await call('/positions/' + debt + '/links', 'PUT', { targetIds: [asset, bank] }, a.cookie)) + .status, + 200, + ); + assert.equal((await call('/overview', 'GET', undefined, a.cookie)).data.net, '550.10'); + assert.equal( + ( + await call( + '/positions/' + bank + '/revisions', + 'POST', + { amount: '110.10', date: '2026-09-02' }, + a.cookie, + ) + ).status, + 201, + ); + const d = (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data; + assert.equal( + ( + await call( + '/positions/' + bank + '/revisions/' + d.history[0].id, + 'PUT', + { amount: '90.10', date: '2026-09-01' }, + b.cookie, + ) + ).status, + 404, + ); + assert.equal( + ( + await call( + '/positions/' + bank + '/revisions/' + d.history[0].id, + 'PUT', + { amount: '90.10', date: '2026-09-01' }, + a.cookie, + ) + ).status, + 200, + ); + assert.equal( + (await call('/positions/' + bank, 'GET', undefined, a.cookie)).data.history[1].delta, + '20', + ); + assert.equal( + ( + await call( + '/positions/' + card + '/revisions', + 'POST', + { amount: '40', date: '2026-09-02', reason: 'repayment' }, + a.cookie, + ) + ).status, + 201, + ); + assert.equal( + ( + await call( + '/positions/' + card, + 'PATCH', + { name: 'card', category: 'credit_card', notes: '', archived: true }, + a.cookie, + ) + ).status, + 200, + ); + assert.equal( + ( + await call( + '/positions/' + card + '/revisions', + 'POST', + { amount: '0', date: '2026-09-03' }, + a.cookie, + ) + ).status, + 409, + ); + o = (await call('/overview', 'GET', undefined, a.cookie)).data; + assert.equal(o.net, '570.10'); + const backup = (await call('/backup', 'GET', undefined, a.cookie)).data; + assert.equal(backup.positions.length, 4); + assert.equal(backup.links.length, 2); + assert.doesNotMatch(JSON.stringify(backup), /password|token|userId|session/i); + assert.equal((await call('/backup/preview', 'POST', backup, a.cookie)).status, 409); + assert.equal((await call('/backup/preview', 'POST', backup, b.cookie)).status, 201); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: false, backup }, b.cookie)).status, + 400, + ); + assert.equal( + ( + await call( + '/backup/import', + 'POST', + { + confirmed: true, + backup: { ...backup, links: [{ sourceId: randomUUID(), targetId: asset }] }, + }, + b.cookie, + ) + ).status, + 400, + ); + assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 0); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + 201, + ); + assert.equal((await call('/overview', 'GET', undefined, b.cookie)).data.net, o.net); + const restored = (await call('/positions', 'GET', undefined, b.cookie)).data; + assert.equal(restored.length, 4); + assert.ok( + restored.every( + (p: { id: string }) => !backup.positions.some((q: { id: string }) => q.id === p.id), + ), + ); + assert.equal(restored.find((p: { kind: string }) => p.kind === 'debt').outgoing.length, 2); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + 409, + ); + assert.equal((await call('/positions', 'GET', undefined, b.cookie)).data.length, 4); + const restoredBank = restored.find( + (p: { kind: string; side: string; currency: string }) => + p.kind === 'account' && p.side === 'asset' && p.currency === 'CNY', + ); + await call( + '/positions/' + restoredBank.id, + 'PATCH', + { + name: 'Edited imported project', + category: restoredBank.category, + notes: 'Edited after import', + archived: false, + }, + b.cookie, + ); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup }, b.cookie)).status, + 409, + ); + const c = await account(); + const racing = await Promise.all([ + call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie), + call('/backup/import', 'POST', { confirmed: true, backup }, c.cookie), + ]); + assert.deepEqual(racing.map((r) => r.status).sort(), [201, 409]); + assert.equal((await call('/positions', 'GET', undefined, c.cookie)).data.length, 4); + assert.equal((await call('/auth/logout', 'POST', undefined, a.cookie)).status, 201); + assert.equal((await call('/auth/me', 'GET', undefined, a.cookie)).status, 401); + assert.equal((await call('/backup', 'GET', undefined, a.cookie)).status, 401); + assert.equal( + (await call('/backup/import', 'POST', { confirmed: true, backup }, a.cookie)).status, + 401, + ); + const login = await call('/auth/login', 'POST', { username: a.username, password: a.password }); + assert.equal(login.status, 201); + assert.equal((await call('/positions', 'GET', undefined, login.cookie)).data.length, 4); + } finally { + for (const u of created) + await db.user.deleteMany({ where: { id: u.id, username: u.username } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 66aa832..d8e1288 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -10,7 +10,5 @@ "emitDecoratorMetadata": true, "skipLibCheck": true }, - "include": [ - "src/**/*.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9e33e77..3803d4f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,3 +1,6 @@ -import {defineConfig} from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig({plugins:[react()],server:{port:5173,proxy:{'/api':'http://127.0.0.1:3000'}}}); +export default defineConfig({ + plugins: [react()], + server: { port: 5173, proxy: { '/api': 'http://127.0.0.1:3100' } }, +}); diff --git a/package.json b/package.json index 9f8c9af..15aed01 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ "test": "pnpm --filter @worthpath/api test", "db:generate": "pnpm --filter @worthpath/api db:generate", "db:migrate": "pnpm --filter @worthpath/api db:migrate" + }, + "devDependencies": { + "prettier": "3.6.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45c4429..1509673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,11 @@ settings: importers: - .: {} + .: + devDependencies: + prettier: + specifier: 3.6.0 + version: 3.6.0 apps/api: dependencies: @@ -34,6 +38,9 @@ importers: dotenv: specifier: ^17.2.0 version: 17.4.2 + express: + specifier: 5.1.0 + version: 5.1.0 helmet: specifier: ^8.1.0 version: 8.3.0 @@ -846,6 +853,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1107,6 +1118,11 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + prettier@3.6.0: + resolution: {integrity: sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==} + engines: {node: '>=14'} + hasBin: true + prisma@6.19.0: resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==} engines: {node: '>=18.18'} @@ -1995,6 +2011,38 @@ snapshots: etag@1.8.1: {} + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2255,6 +2303,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.6.0: {} + prisma@6.19.0(typescript@5.9.3): dependencies: '@prisma/config': 6.19.0 diff --git a/scripts/check-staged.mjs b/scripts/check-staged.mjs new file mode 100644 index 0000000..4054367 --- /dev/null +++ b/scripts/check-staged.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); +const names = git('diff', '--cached', '--name-only').trim().split('\n').filter(Boolean); +if ( + names.some( + (n) => + /(^|\/)(\.env(?:\..*)?|node_modules|dist|coverage)(\/|$)/.test(n) && + !n.endsWith('.env.example'), + ) || + names.some((n) => /\.(db|sqlite|log)$/.test(n)) +) + throw Error('Blocked: forbidden file staged'); +const diff = git('diff', '--cached', '--no-ext-diff'); +if (existsSync('apps/api/.env')) { + const text = readFileSync('apps/api/.env', 'utf8'), + value = text.match(/^DATABASE_URL=["']?([^"'\r\n]+)/m)?.[1]; + if (value) { + const u = new URL(value); + for (const s of [decodeURIComponent(u.password), u.hostname]) + if (s.length >= 6 && diff.includes(s)) + throw Error('Blocked: local credential or connection metadata in staged changes'); + } +} +if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9]{20,}/.test(diff)) + throw Error('Blocked: secret-like material staged'); +console.log( + `Staged review passed: ${names.length} files; local environment and build artifacts excluded.`, +);