chore: establish workspace and initial asset data model

This commit is contained in:
陈煜 committed 2026-10-01 15:47:36 +08:00
commit 67220e38ed
20 files changed
+2853

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.log
*.db
*.sqlite*
.pnpm-store/
+1
View File
@@ -0,0 +1 @@
manage-package-manager-versions=false
+22
View File
@@ -0,0 +1,22 @@
# WorthPath
个人资产负债与净资产管理。Node.js 22.12+、pnpm 11、MySQL 8+。
```powershell
pnpm install
Copy-Item apps/api/.env.example apps/api/.env
# 仅在本地 .env 设置 DATABASE_URL,先检查数据库是否存在
pnpm db:generate
pnpm db:migrate
pnpm dev
```
前端 http://localhost:5173,API http://localhost:3000/api。
```powershell
pnpm typecheck
pnpm build
pnpm test
```
设计见 docs/architecture.md。生产需要 HTTPS、COOKIE_SECURE=true、WEB_ORIGIN 为准确站点地址;前端构建由反向代理托管并代理 /api。不要公开开发服务器。
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath"
PORT=3000
WEB_ORIGIN=http://localhost:5173
COOKIE_SECURE=false
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@worthpath/api",
"private": true,
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test test/*.test.ts",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@prisma/client": "6.19.0",
"bcryptjs": "^3.0.0",
"cookie-parser": "^1.4.7",
"decimal.js": "^10.6.0",
"dotenv": "^17.2.0",
"helmet": "^8.1.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"zod": "^4.1.0"
},
"devDependencies": {
"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"
}
}
@@ -0,0 +1,97 @@
-- CreateTable
CREATE TABLE `User` (
`id` CHAR(36) NOT NULL,
`username` VARCHAR(64) NOT NULL,
`passwordHash` VARCHAR(255) NOT NULL,
`baseCurrency` CHAR(3) NOT NULL DEFAULT 'CNY',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `User_username_key`(`username`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Session` (
`id` CHAR(64) NOT NULL,
`userId` CHAR(36) NOT NULL,
`expiresAt` DATETIME(3) NOT NULL,
INDEX `Session_userId_idx`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Position` (
`id` CHAR(36) NOT NULL,
`userId` CHAR(36) NOT NULL,
`kind` VARCHAR(16) NOT NULL,
`side` VARCHAR(16) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`category` VARCHAR(40) NOT NULL,
`currency` CHAR(3) NOT NULL,
`notes` TEXT NOT NULL,
`archived` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Position_userId_kind_idx`(`userId`, `kind`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Revision` (
`id` CHAR(36) NOT NULL,
`positionId` CHAR(36) NOT NULL,
`amount` DECIMAL(24, 8) NOT NULL,
`effectiveDate` DATE NOT NULL,
`notes` TEXT NOT NULL,
`reason` VARCHAR(20) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `Revision_positionId_effectiveDate_key`(`positionId`, `effectiveDate`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PositionLink` (
`id` CHAR(36) NOT NULL,
`sourceId` CHAR(36) NOT NULL,
`targetId` CHAR(36) NOT NULL,
UNIQUE INDEX `PositionLink_sourceId_targetId_key`(`sourceId`, `targetId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ExchangeRate` (
`id` CHAR(36) NOT NULL,
`userId` CHAR(36) NOT NULL,
`currency` CHAR(3) NOT NULL,
`baseCurrency` CHAR(3) NOT NULL,
`date` DATE NOT NULL,
`rate` DECIMAL(24, 12) NOT NULL,
`source` VARCHAR(30) NOT NULL,
UNIQUE INDEX `ExchangeRate_userId_currency_baseCurrency_date_key`(`userId`, `currency`, `baseCurrency`, `date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Session` ADD CONSTRAINT `Session_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Position` ADD CONSTRAINT `Position_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Revision` ADD CONSTRAINT `Revision_positionId_fkey` FOREIGN KEY (`positionId`) REFERENCES `Position`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PositionLink` ADD CONSTRAINT `PositionLink_sourceId_fkey` FOREIGN KEY (`sourceId`) REFERENCES `Position`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PositionLink` ADD CONSTRAINT `PositionLink_targetId_fkey` FOREIGN KEY (`targetId`) REFERENCES `Position`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ExchangeRate` ADD CONSTRAINT `ExchangeRate_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
provider = "mysql"
+74
View File
@@ -0,0 +1,74 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid()) @db.Char(36)
username String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
baseCurrency String @default("CNY") @db.Char(3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
positions Position[]
rates ExchangeRate[]
sessions Session[]
}
model Session {
id String @id @db.Char(64)
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
expiresAt DateTime
@@index([userId])
}
model Position {
id String @id @default(uuid()) @db.Char(36)
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
kind String @db.VarChar(16)
side String @db.VarChar(16)
name String @db.VarChar(100)
category String @db.VarChar(40)
currency String @db.Char(3)
notes String @db.Text
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
revisions Revision[]
outgoing PositionLink[] @relation("Source")
incoming PositionLink[] @relation("Target")
@@index([userId,kind])
}
model Revision {
id String @id @default(uuid()) @db.Char(36)
positionId String @db.Char(36)
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
amount Decimal @db.Decimal(24,8)
effectiveDate DateTime @db.Date
notes String @db.Text
reason String @db.VarChar(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([positionId,effectiveDate])
}
model PositionLink {
id String @id @default(uuid()) @db.Char(36)
sourceId String @db.Char(36)
targetId String @db.Char(36)
source Position @relation("Source",fields:[sourceId],references:[id],onDelete:Cascade)
target Position @relation("Target",fields:[targetId],references:[id],onDelete:Cascade)
@@unique([sourceId,targetId])
}
model ExchangeRate {
id String @id @default(uuid()) @db.Char(36)
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
currency String @db.Char(3)
baseCurrency String @db.Char(3)
date DateTime @db.Date
rate Decimal @db.Decimal(24,12)
source String @db.VarChar(30)
@@unique([userId,currency,baseCurrency,date])
}
+3
View File
@@ -0,0 +1,3 @@
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();}})();
+8
View File
@@ -0,0 +1,8 @@
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();
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts"
]
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>WorthPath · 资产之路</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@worthpath/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.9.0",
"vite": "^7.1.0",
"@vitejs/plugin-react": "^5.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
import React from 'react';import{createRoot}from'react-dom/client';createRoot(document.getElementById('root')!).render(<React.StrictMode><h1>WorthPath</h1><p>资产管理服务正在搭建。</p></React.StrictMode>);
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": [
"src",
"vite.config.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
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'}}});
+17
View File
@@ -0,0 +1,17 @@
# WorthPath 首版设计
NestJS API、React/Vite 前端和 MySQL;同源 /api,开发环境由 Vite 代理。
用户拥有持有项目 Position(account/asset/debt)、汇率及登录会话。账户独立保留 asset/liability 属性;信用卡与贷款固定为负债。债务可链接到多个同用户项目,链接不参与求和。每个金额均为非负原币余额,负债按 side 减去。
Revision 保存按业务日期生效的绝对金额,每对象每日一条记录;同日更正须明确调用更正接口。历史前值和变化额按业务日期重新计算,避免更正早期记录后余额与趋势不一致。未来日期不允许。初始余额也写入历史。金额 DECIMAL(24,8),汇率 DECIMAL(24,12),API 使用十进制字符串;计算使用 Decimal,前端仅图形坐标使用 Number。
趋势按日期重放金额和当时可用汇率。汇率导致的变化和余额导致的变化分别归因;没有可用汇率时总额标记不完整,绝不默认为 1。归档项目仍参与统计,归档仅停止编辑,避免归档导致财富凭空消失。
认证采用 bcrypt 密码哈希和 HttpOnly 随机会话 Cookie;数据库只保存会话令牌 SHA-256 摘要。所有资源查询由会话用户范围限定。写入要求同源 Origin,登录限速;生产必须 HTTPS 并启用安全 Cookie。
备份采用 version=1 JSON,包含项目、历史、关系、汇率、本位币,不包含用户认证数据。首版导入只追加完整的新项目并重映射 ID;完全相同的重复导入拒绝;汇率冲突拒绝;已有本位币不自动更改。完整验证后事务写入,不修改已有项目。
页面:注册登录、总览、账户/资产/债务列表及详情与编辑、历史、本位币/汇率设置、备份与导入。空数据无演示金额。
数据库使用 Prisma 可追踪 SQL 迁移,部署仅 migrate deploy,禁止 db push/reset。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
+13
View File
@@ -0,0 +1,13 @@
{
"name": "worthpath",
"private": true,
"packageManager": "pnpm@11.19.0",
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"test": "pnpm --filter @worthpath/api test",
"db:generate": "pnpm --filter @worthpath/api db:generate",
"db:migrate": "pnpm --filter @worthpath/api db:migrate"
}
}
+2497
View File
File diff suppressed because it is too large. Load diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- apps/*
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
prisma: true
esbuild: true