chore: establish workspace and initial asset data model
This commit is contained in:
commit
67220e38ed
20 files changed
+2853
No files matched your search
+10
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite*
|
||||
.pnpm-store/
|
||||
@@ -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。不要公开开发服务器。
|
||||
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL="mysql://USER:PASSWORD@HOST:3306/worthpath"
|
||||
PORT=3000
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
COOKIE_SECURE=false
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();}})();
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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'}}});
|
||||
@@ -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。扩展家庭共享时可以引入空间和成员权限,现阶段严格按用户隔离。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2497
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
packages:
|
||||
- apps/*
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
prisma: true
|
||||
esbuild: true
|
||||
Reference in new issue
Block a user