create project

This commit is contained in:
2026-05-19 11:46:42 +08:00
commit dcdf1f60b1
60 changed files with 9309 additions and 0 deletions

34
backend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Stage 1: 依赖安装 + 构建
FROM node:20-alpine AS builder
WORKDIR /app
# 先装依赖(缓存最大化)
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# Prisma 客户端生成
COPY prisma/ ./prisma/
RUN npx prisma generate
# 复制源码并构建
COPY . .
RUN npm run build
# Stage 2: 最小运行时镜像
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Standalone output 包含所有必要运行时文件
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
EXPOSE 3000
CMD ["node", "server.js"]

5
backend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

6
backend/next.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
};
export default nextConfig;

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "backend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"prisma": "^5.14.0",
"@prisma/client": "^5.14.0",
"zod": "^3.23.0",
"jose": "^5.3.0",
"bcryptjs": "^2.4.3"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/bcryptjs": "^2.4.0",
"tsx": "^4.11.0"
}
}

748
backend/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,748 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@prisma/client':
specifier: ^5.14.0
version: 5.22.0(prisma@5.22.0)
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
jose:
specifier: ^5.3.0
version: 5.10.0
next:
specifier: ^14.2.0
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
prisma:
specifier: ^5.14.0
version: 5.22.0
react:
specifier: ^18.3.0
version: 18.3.1
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
zod:
specifier: ^3.23.0
version: 3.25.76
devDependencies:
'@types/bcryptjs':
specifier: ^2.4.0
version: 2.4.6
'@types/node':
specifier: ^20.0.0
version: 20.19.41
'@types/react':
specifier: ^18.3.0
version: 18.3.28
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.28)
tsx:
specifier: ^4.11.0
version: 4.21.0
typescript:
specifier: ^5.4.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.7':
resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@next/env@14.2.35':
resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==}
'@next/swc-darwin-arm64@14.2.33':
resolution: {integrity: sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@14.2.33':
resolution: {integrity: sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@14.2.33':
resolution: {integrity: sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.33':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@14.2.33':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@14.2.33':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.33':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-ia32-msvc@14.2.33':
resolution: {integrity: sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@next/swc-win32-x64-msvc@14.2.33':
resolution: {integrity: sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@prisma/client@5.22.0':
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
engines: {node: '>=16.13'}
peerDependencies:
prisma: '*'
peerDependenciesMeta:
prisma:
optional: true
'@prisma/debug@5.22.0':
resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==}
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2':
resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==}
'@prisma/engines@5.22.0':
resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==}
'@prisma/fetch-engine@5.22.0':
resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==}
'@prisma/get-platform@5.22.0':
resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==}
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@14.2.35:
resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.41.2
react: ^18.2.0
react-dom: ^18.2.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
sass:
optional: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
prisma@5.22.0:
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
engines: {node: '>=16.13'}
hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
styled-jsx@5.1.1:
resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
babel-plugin-macros:
optional: true
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.7':
optional: true
'@esbuild/android-arm64@0.27.7':
optional: true
'@esbuild/android-arm@0.27.7':
optional: true
'@esbuild/android-x64@0.27.7':
optional: true
'@esbuild/darwin-arm64@0.27.7':
optional: true
'@esbuild/darwin-x64@0.27.7':
optional: true
'@esbuild/freebsd-arm64@0.27.7':
optional: true
'@esbuild/freebsd-x64@0.27.7':
optional: true
'@esbuild/linux-arm64@0.27.7':
optional: true
'@esbuild/linux-arm@0.27.7':
optional: true
'@esbuild/linux-ia32@0.27.7':
optional: true
'@esbuild/linux-loong64@0.27.7':
optional: true
'@esbuild/linux-mips64el@0.27.7':
optional: true
'@esbuild/linux-ppc64@0.27.7':
optional: true
'@esbuild/linux-riscv64@0.27.7':
optional: true
'@esbuild/linux-s390x@0.27.7':
optional: true
'@esbuild/linux-x64@0.27.7':
optional: true
'@esbuild/netbsd-arm64@0.27.7':
optional: true
'@esbuild/netbsd-x64@0.27.7':
optional: true
'@esbuild/openbsd-arm64@0.27.7':
optional: true
'@esbuild/openbsd-x64@0.27.7':
optional: true
'@esbuild/openharmony-arm64@0.27.7':
optional: true
'@esbuild/sunos-x64@0.27.7':
optional: true
'@esbuild/win32-arm64@0.27.7':
optional: true
'@esbuild/win32-ia32@0.27.7':
optional: true
'@esbuild/win32-x64@0.27.7':
optional: true
'@next/env@14.2.35': {}
'@next/swc-darwin-arm64@14.2.33':
optional: true
'@next/swc-darwin-x64@14.2.33':
optional: true
'@next/swc-linux-arm64-gnu@14.2.33':
optional: true
'@next/swc-linux-arm64-musl@14.2.33':
optional: true
'@next/swc-linux-x64-gnu@14.2.33':
optional: true
'@next/swc-linux-x64-musl@14.2.33':
optional: true
'@next/swc-win32-arm64-msvc@14.2.33':
optional: true
'@next/swc-win32-ia32-msvc@14.2.33':
optional: true
'@next/swc-win32-x64-msvc@14.2.33':
optional: true
'@prisma/client@5.22.0(prisma@5.22.0)':
optionalDependencies:
prisma: 5.22.0
'@prisma/debug@5.22.0': {}
'@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {}
'@prisma/engines@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
'@prisma/fetch-engine': 5.22.0
'@prisma/get-platform': 5.22.0
'@prisma/fetch-engine@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2
'@prisma/get-platform': 5.22.0
'@prisma/get-platform@5.22.0':
dependencies:
'@prisma/debug': 5.22.0
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5':
dependencies:
'@swc/counter': 0.1.3
tslib: 2.8.1
'@types/bcryptjs@2.4.6': {}
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.28)':
dependencies:
'@types/react': 18.3.28
'@types/react@18.3.28':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
bcryptjs@2.4.3: {}
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
caniuse-lite@1.0.30001792: {}
client-only@0.0.1: {}
csstype@3.2.3: {}
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
'@esbuild/android-arm': 0.27.7
'@esbuild/android-arm64': 0.27.7
'@esbuild/android-x64': 0.27.7
'@esbuild/darwin-arm64': 0.27.7
'@esbuild/darwin-x64': 0.27.7
'@esbuild/freebsd-arm64': 0.27.7
'@esbuild/freebsd-x64': 0.27.7
'@esbuild/linux-arm': 0.27.7
'@esbuild/linux-arm64': 0.27.7
'@esbuild/linux-ia32': 0.27.7
'@esbuild/linux-loong64': 0.27.7
'@esbuild/linux-mips64el': 0.27.7
'@esbuild/linux-ppc64': 0.27.7
'@esbuild/linux-riscv64': 0.27.7
'@esbuild/linux-s390x': 0.27.7
'@esbuild/linux-x64': 0.27.7
'@esbuild/netbsd-arm64': 0.27.7
'@esbuild/netbsd-x64': 0.27.7
'@esbuild/openbsd-arm64': 0.27.7
'@esbuild/openbsd-x64': 0.27.7
'@esbuild/openharmony-arm64': 0.27.7
'@esbuild/sunos-x64': 0.27.7
'@esbuild/win32-arm64': 0.27.7
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
fsevents@2.3.3:
optional: true
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
graceful-fs@4.2.11: {}
jose@5.10.0: {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
nanoid@3.3.12: {}
next@14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.2.35
'@swc/helpers': 0.5.5
busboy: 1.6.0
caniuse-lite: 1.0.30001792
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
styled-jsx: 5.1.1(react@18.3.1)
optionalDependencies:
'@next/swc-darwin-arm64': 14.2.33
'@next/swc-darwin-x64': 14.2.33
'@next/swc-linux-arm64-gnu': 14.2.33
'@next/swc-linux-arm64-musl': 14.2.33
'@next/swc-linux-x64-gnu': 14.2.33
'@next/swc-linux-x64-musl': 14.2.33
'@next/swc-win32-arm64-msvc': 14.2.33
'@next/swc-win32-ia32-msvc': 14.2.33
'@next/swc-win32-x64-msvc': 14.2.33
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
picocolors@1.1.1: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
prisma@5.22.0:
dependencies:
'@prisma/engines': 5.22.0
optionalDependencies:
fsevents: 2.3.3
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
resolve-pkg-maps@1.0.0: {}
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
source-map-js@1.2.1: {}
streamsearch@1.1.0: {}
styled-jsx@5.1.1(react@18.3.1):
dependencies:
client-only: 0.0.1
react: 18.3.1
tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.7
get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@6.21.0: {}
zod@3.25.76: {}

View File

@@ -0,0 +1,60 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
phone String @unique
passwordHash String
nickname String
email String?
familyIds String[]
activeFamilyId String?
joinDate DateTime @default(now())
createdAt DateTime @default(now())
bills Bill[]
familiesCreated Family[] @relation("FamilyCreator")
}
model Family {
id String @id @default(cuid())
name String
inviteCode String @unique
createdBy String
creator User @relation("FamilyCreator", fields: [createdBy], references: [id])
createdAt DateTime @default(now())
members FamilyMember[]
bills Bill[]
}
model FamilyMember {
id String @id @default(cuid())
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
userId String
nickname String
role String
joinedAt DateTime @default(now())
@@unique([familyId, userId])
}
model Bill {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
familyId String?
family Family? @relation(fields: [familyId], references: [id])
type String
amount Float
category String
note String?
emoji String?
time String
date DateTime
createdAt DateTime @default(now())
}

75
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,75 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
const passwordHash = await bcrypt.hash('123456', 10);
const user = await prisma.user.upsert({
where: { phone: '13800138000' },
update: {},
create: {
phone: '13800138000',
passwordHash,
nickname: '小明',
joinDate: new Date('2026-03-01'),
},
});
const family = await prisma.family.upsert({
where: { id: 'f_preset_home' },
update: {},
create: {
id: 'f_preset_home',
name: '小明的家',
inviteCode: 'ABCDEF',
createdBy: user.id,
members: {
create: {
userId: user.id,
nickname: '小明',
role: 'owner',
},
},
},
});
await prisma.user.update({
where: { id: user.id },
data: {
familyIds: [family.id],
activeFamilyId: family.id,
},
});
const sampleBills = [
{ type: 'expense' as const, amount: 32, category: '餐饮', emoji: '🍜', note: '午餐-兰州拉面', time: '12:30', date: new Date('2026-05-01') },
{ type: 'expense' as const, amount: 15, category: '交通', emoji: '🚇', note: '地铁通勤', time: '08:45', date: new Date('2026-05-02') },
{ type: 'expense' as const, amount: 128, category: '购物', emoji: '🛒', note: '超市采购', time: '18:20', date: new Date('2026-05-03') },
{ type: 'expense' as const, amount: 45, category: '餐饮', emoji: '☕', note: '星巴克咖啡', time: '15:00', date: new Date('2026-05-03') },
{ type: 'income' as const, amount: 15000, category: '工资', emoji: '💰', note: '5月工资', time: '10:00', date: new Date('2026-05-05') },
{ type: 'expense' as const, amount: 200, category: '居住', emoji: '🏠', note: '物业费', time: '09:00', date: new Date('2026-05-06') },
{ type: 'expense' as const, amount: 88, category: '餐饮', emoji: '🍣', note: '和朋友聚餐', time: '19:30', date: new Date('2026-05-07') },
{ type: 'expense' as const, amount: 56, category: '交通', emoji: '🚕', note: '打车回家', time: '22:15', date: new Date('2026-05-08') },
];
for (const bill of sampleBills) {
await prisma.bill.create({
data: {
userId: user.id,
familyId: family.id,
...bill,
},
});
}
console.log('✅ 种子数据已创建:');
console.log(' 预设账号: 13800138000 / 123456');
console.log(' 家庭: 小明的家');
console.log(' 账单: 8 条示例');
}
main()
.catch((e) => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());

44
backend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { SignJWT, jwtVerify } from 'jose';
import { NextApiRequest, NextApiResponse } from 'next';
const secret = new TextEncoder().encode(process.env.JWT_SECRET || 'fallback-secret');
export async function signToken(payload: { userId: string; phone: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload as { userId: string; phone: string };
} catch {
return null;
}
}
export interface AuthenticatedRequest extends NextApiRequest {
user?: { userId: string; phone: string };
}
export function withAuth(
handler: (req: AuthenticatedRequest, res: NextApiResponse) => Promise<void>
) {
return async (req: AuthenticatedRequest, res: NextApiResponse) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: '未提供认证令牌' });
return;
}
const token = authHeader.slice(7);
const payload = await verifyToken(token);
if (!payload) {
res.status(401).json({ success: false, error: '令牌无效或已过期' });
return;
}
req.user = payload;
return handler(req, res);
};
}

View File

@@ -0,0 +1,19 @@
const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function randomChar(): string {
return CHARS[Math.floor(Math.random() * CHARS.length)];
}
export function generateInviteCode(): string {
return Array.from({ length: 6 }, () => randomChar()).join('');
}
export async function createUniqueInviteCode(
checkExists: (code: string) => Promise<boolean>
): Promise<string> {
let code: string;
do {
code = generateInviteCode();
} while (await checkExists(code));
return code;
}

View File

@@ -0,0 +1,7 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,9 @@
import { NextApiResponse } from 'next';
export function success<T>(res: NextApiResponse, data: T, status = 200) {
return res.status(status).json({ success: true, data });
}
export function error(res: NextApiResponse, message: string, status = 400) {
return res.status(status).json({ success: false, error: message });
}

View File

@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { signToken } from '@/lib/auth';
import { success, error } from '@/lib/response';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/),
password: z.string().min(1),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
const { phone, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { phone } });
if (!user) return error(res, '手机号未注册');
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return error(res, '密码错误');
const token = await signToken({ userId: user.id, phone: user.phone });
return success(res, {
token,
user: {
id: user.id,
phone: user.phone,
nickname: user.nickname,
email: user.email,
familyIds: user.familyIds,
activeFamilyId: user.activeFamilyId,
joinDate: user.joinDate.toISOString().slice(0, 7),
createdAt: user.createdAt.toISOString(),
},
});
}

View File

@@ -0,0 +1,10 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { success } from '@/lib/response';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.status(405).json({ success: false, error: 'Method not allowed' });
return;
}
return success(res, { message: '已登出' });
}

View File

@@ -0,0 +1,45 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { signToken } from '@/lib/auth';
import { success, error } from '@/lib/response';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, '手机号格式不正确'),
password: z.string().min(6, '密码至少6位'),
nickname: z.string().min(1, '昵称不能为空').max(20),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
const { phone, password, nickname } = parsed.data;
const existing = await prisma.user.findUnique({ where: { phone } });
if (existing) return error(res, '该手机号已注册');
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { phone, passwordHash, nickname, joinDate: new Date() },
});
const token = await signToken({ userId: user.id, phone: user.phone });
return success(res, {
token,
user: {
id: user.id,
phone: user.phone,
nickname: user.nickname,
email: user.email,
familyIds: user.familyIds,
activeFamilyId: user.activeFamilyId,
joinDate: user.joinDate.toISOString().slice(0, 7),
createdAt: user.createdAt.toISOString(),
},
}, 201);
}

View File

@@ -0,0 +1,56 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { z } from 'zod';
const createSchema = z.object({
type: z.enum(['expense', 'income']),
amount: z.number().positive(),
category: z.string().min(1),
note: z.string().optional().default(''),
emoji: z.string().optional().default('📝'),
time: z.string().regex(/^\d{2}:\d{2}$/),
date: z.string(),
familyId: z.string().nullable().optional().default(null),
});
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const { userId, familyId } = req.query;
const where: any = { userId: req.user!.userId };
if (familyId) {
where.familyId = familyId as string;
} else if (!userId) {
where.familyId = null;
}
const bills = await prisma.bill.findMany({
where,
orderBy: { date: 'desc' },
take: 100,
});
return success(res, bills);
}
if (req.method === 'POST') {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
const bill = await prisma.bill.create({
data: {
userId: req.user!.userId,
...parsed.data,
date: new Date(parsed.data.date),
},
});
return success(res, bill, 201);
}
return error(res, 'Method not allowed', 405);
}
export default withAuth(handler);

View File

@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'GET') return error(res, 'Method not allowed', 405);
const { id } = req.query;
const family = await prisma.family.findUnique({
where: { id: id as string },
include: { members: true },
});
if (!family) return error(res, '家庭不存在', 404);
return success(res, family);
}
export default withAuth(handler);

View File

@@ -0,0 +1,64 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { createUniqueInviteCode } from '@/lib/invite-code';
import { z } from 'zod';
const createSchema = z.object({ name: z.string().min(1).max(30) });
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) return error(res, '用户不存在', 404);
const families = await prisma.family.findMany({
where: { id: { in: user.familyIds } },
include: { members: true },
});
return success(res, families);
}
if (req.method === 'POST') {
const parsed = createSchema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
const inviteCode = await createUniqueInviteCode(async (code) => {
const existing = await prisma.family.findUnique({ where: { inviteCode: code } });
return !!existing;
});
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) return error(res, '用户不存在', 404);
const family = await prisma.family.create({
data: {
name: parsed.data.name,
inviteCode,
createdBy: req.user!.userId,
members: {
create: {
userId: req.user!.userId,
nickname: user.nickname,
role: 'owner',
},
},
},
include: { members: true },
});
await prisma.user.update({
where: { id: req.user!.userId },
data: {
familyIds: { push: family.id },
activeFamilyId: family.id,
},
});
return success(res, family, 201);
}
return error(res, 'Method not allowed', 405);
}
export default withAuth(handler);

View File

@@ -0,0 +1,48 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { z } from 'zod';
const schema = z.object({ inviteCode: z.string().length(6) });
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'POST') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, '邀请码格式不正确');
const family = await prisma.family.findUnique({
where: { inviteCode: parsed.data.inviteCode },
include: { members: true },
});
if (!family) return error(res, '邀请码无效');
if (family.members.some(m => m.userId === req.user!.userId)) {
return error(res, '你已是该家庭成员');
}
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) return error(res, '用户不存在', 404);
await prisma.familyMember.create({
data: {
familyId: family.id,
userId: req.user!.userId,
nickname: user.nickname,
role: 'member',
},
});
await prisma.user.update({
where: { id: req.user!.userId },
data: {
familyIds: [...user.familyIds, family.id],
activeFamilyId: family.id,
},
});
return success(res, { message: '加入成功', family });
}
export default withAuth(handler);

View File

@@ -0,0 +1,34 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { z } from 'zod';
const schema = z.object({ familyId: z.string() });
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'POST') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, '参数错误');
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) return error(res, '用户不存在', 404);
await prisma.familyMember.deleteMany({
where: { familyId: parsed.data.familyId, userId: req.user!.userId },
});
const newFamilyIds = user.familyIds.filter(id => id !== parsed.data.familyId);
await prisma.user.update({
where: { id: req.user!.userId },
data: {
familyIds: newFamilyIds,
activeFamilyId: newFamilyIds.length > 0 ? newFamilyIds[0] : null,
},
});
return success(res, { message: '已退出家庭' });
}
export default withAuth(handler);

View File

@@ -0,0 +1,10 @@
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = { name: string };
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>,
) {
res.status(200).json({ name: 'Family Accounting API' });
}

View File

@@ -0,0 +1,25 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { z } from 'zod';
const schema = z.object({
familyId: z.string().nullable(),
});
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'PUT') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
await prisma.user.update({
where: { id: req.user!.userId },
data: { activeFamilyId: parsed.data.familyId },
});
return success(res, { message: '切换成功' });
}
export default withAuth(handler);

View File

@@ -0,0 +1,25 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('邮箱格式不正确'),
});
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'PUT') return error(res, 'Method not allowed', 405);
const parsed = schema.safeParse(req.body);
if (!parsed.success) return error(res, parsed.error.errors[0].message);
await prisma.user.update({
where: { id: req.user!.userId },
data: { email: parsed.data.email },
});
return success(res, { message: '邮箱绑定成功' });
}
export default withAuth(handler);

View File

@@ -0,0 +1,24 @@
import type { NextApiResponse } from 'next';
import { prisma } from '@/lib/prisma';
import { withAuth, AuthenticatedRequest } from '@/lib/auth';
import { success, error } from '@/lib/response';
async function handler(req: AuthenticatedRequest, res: NextApiResponse) {
if (req.method !== 'GET') return error(res, 'Method not allowed', 405);
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
if (!user) return error(res, '用户不存在', 404);
return success(res, {
id: user.id,
phone: user.phone,
nickname: user.nickname,
email: user.email,
familyIds: user.familyIds,
activeFamilyId: user.activeFamilyId,
joinDate: user.joinDate.toISOString().slice(0, 7),
createdAt: user.createdAt.toISOString(),
});
}
export default withAuth(handler);

21
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}