feat(api): implement secure portfolio history, daily FX and atomic backups

This commit is contained in:
陈煜 committed 2026-10-01 16:21:16 +08:00
1 parent 67220e38ed
commit 99174a3da5
25 files changed
+1750 -28

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
**/node_modules/**
**/dist/**
**/.env*
pnpm-lock.yaml
+1
View File
@@ -0,0 +1 @@
{"singleQuote":true,"trailingComma":"all","printWidth":100}
+1 -1
View File
@@ -11,7 +11,7 @@ pnpm db:migrate
pnpm dev pnpm dev
``` ```
前端 http://localhost:5173,API http://localhost:3000/api。 前端 http://localhost:5173,API http://localhost:3100/api。
```powershell ```powershell
pnpm typecheck pnpm typecheck
+1 -1
View File
@@ -1,4 +1,4 @@
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath" DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath"
PORT=3000 PORT=3100
WEB_ORIGIN=http://localhost:5173 WEB_ORIGIN=http://localhost:5173
COOKIE_SECURE=false COOKIE_SECURE=false
+12 -9
View File
@@ -2,12 +2,14 @@
"name": "@worthpath/api", "name": "@worthpath/api",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "tsx watch src/main.ts", "dev": "node scripts/dev.cjs",
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "tsx --test test/*.test.ts", "test": "tsx --test test/calculation.test.ts",
"db:generate": "prisma generate", "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": { "dependencies": {
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
@@ -18,18 +20,19 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"express": "5.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"zod": "^4.1.0" "zod": "^4.1.0"
}, },
"devDependencies": { "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", "prisma": "6.19.0",
"tsx": "^4.20.0", "tsx": "^4.20.0",
"typescript": "^5.9.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"
} }
} }
@@ -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`);
+2
View File
@@ -25,6 +25,7 @@ model Session {
} }
model Position { model Position {
id String @id @default(uuid()) @db.Char(36) id String @id @default(uuid()) @db.Char(36)
importedFromId String? @db.Char(36)
userId String @db.Char(36) userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade) user User @relation(fields:[userId],references:[id],onDelete:Cascade)
kind String @db.VarChar(16) kind String @db.VarChar(16)
@@ -40,6 +41,7 @@ model Position {
outgoing PositionLink[] @relation("Source") outgoing PositionLink[] @relation("Source")
incoming PositionLink[] @relation("Target") incoming PositionLink[] @relation("Target")
@@index([userId,kind]) @@index([userId,kind])
@@unique([userId,importedFromId])
} }
model Revision { model Revision {
id String @id @default(uuid()) @db.Char(36) id String @id @default(uuid()) @db.Char(36)
+19
View File
@@ -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;
+40 -3
View File
@@ -1,3 +1,40 @@
require('dotenv').config({quiet:true}); require('dotenv').config({ quiet: true });
const mysql=require('mysql2/promise'); 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();}})(); (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();
}
})();
+13
View File
@@ -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);
+142
View File
@@ -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<string, { count: number; until: number }>();
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<UserRequest>();
if (
!['GET', 'HEAD', 'OPTIONS'].includes(req.method) &&
req.headers.origin !== process.env.WEB_ORIGIN
)
throw new ForbiddenException('请求来源不受信任');
if (this.reflector.get<boolean>('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 };
}
}
+290
View File
@@ -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<typeof backupSchema>;
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<string>();
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<string>();
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<string>();
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<string>();
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<Backup> {
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<string, string>();
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 },
);
}
}
+136
View File
@@ -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<string>();
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<typeof totals> | 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),
};
}
+11
View File
@@ -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();
}
}
+67 -6
View File
@@ -1,8 +1,69 @@
import 'reflect-metadata'; import 'reflect-metadata';
import 'dotenv/config'; import 'dotenv/config';
import {Module,Controller,Get} from '@nestjs/common'; import { Module, Catch, ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
import {NestFactory} from '@nestjs/core'; import { NestFactory, APP_GUARD } from '@nestjs/core';
import {PrismaClient} from '@prisma/client'; import cookieParser from 'cookie-parser';
@Controller('api') class HealthController {@Get('health') health(){return {status:'ok',app:'WorthPath'};}} import helmet from 'helmet';
@Module({controllers:[HealthController]}) class AppModule {} import { json } from 'express';
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 { 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;
});
+175
View File
@@ -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());
}
}
+192
View File
@@ -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&quotes=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD';
@Injectable()
export class RatesService implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private attempts = new Map<string, string>();
private running = new Set<string>();
private outcomes = new Map<string, { state: string; attemptedAt: string; message: string }>();
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);
}
}
+95
View File
@@ -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();
+136
View File
@@ -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&quotes=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;
}
});
+319
View File
@@ -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();
}
});
+2 -4
View File
@@ -10,7 +10,5 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": [ "include": ["src/**/*.ts"]
"src/**/*.ts" }
]
}
+5 -2
View File
@@ -1,3 +1,6 @@
import {defineConfig} from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; 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' } },
});
+4 -1
View File
@@ -9,5 +9,8 @@
"test": "pnpm --filter @worthpath/api test", "test": "pnpm --filter @worthpath/api test",
"db:generate": "pnpm --filter @worthpath/api db:generate", "db:generate": "pnpm --filter @worthpath/api db:generate",
"db:migrate": "pnpm --filter @worthpath/api db:migrate" "db:migrate": "pnpm --filter @worthpath/api db:migrate"
},
"devDependencies": {
"prettier": "3.6.0"
} }
} }
+51 -1
View File
@@ -6,7 +6,11 @@ settings:
importers: importers:
.: {} .:
devDependencies:
prettier:
specifier: 3.6.0
version: 3.6.0
apps/api: apps/api:
dependencies: dependencies:
@@ -34,6 +38,9 @@ importers:
dotenv: dotenv:
specifier: ^17.2.0 specifier: ^17.2.0
version: 17.4.2 version: 17.4.2
express:
specifier: 5.1.0
version: 5.1.0
helmet: helmet:
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.3.0 version: 8.3.0
@@ -846,6 +853,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
express@5.1.0:
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
engines: {node: '>= 18'}
express@5.2.1: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -1107,6 +1118,11 @@ packages:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14} 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: prisma@6.19.0:
resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==} resolution: {integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
@@ -1995,6 +2011,38 @@ snapshots:
etag@1.8.1: {} 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: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@@ -2255,6 +2303,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
prettier@3.6.0: {}
prisma@6.19.0(typescript@5.9.3): prisma@6.19.0(typescript@5.9.3):
dependencies: dependencies:
'@prisma/config': 6.19.0 '@prisma/config': 6.19.0
+29
View File
@@ -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.`,
);