feat(api): implement secure portfolio history, daily FX and atomic backups
This commit is contained in:
1 parent
67220e38ed
commit
99174a3da5
25 files changed
+1747
-25
No files matched your search
@@ -0,0 +1,4 @@
|
||||
**/node_modules/**
|
||||
**/dist/**
|
||||
**/.env*
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1 @@
|
||||
{"singleQuote":true,"trailingComma":"all","printWidth":100}
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath"
|
||||
PORT=3000
|
||||
PORT=3100
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
COOKIE_SECURE=false
|
||||
+11
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -10,7 +10,5 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
"include": ["src/**/*.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' } },
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+51
-1
@@ -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
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
Reference in new issue
Block a user