为什么还在纠结技术选型?不如三小时撸个“真能上线”的全栈应用,香不香?

🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8
开篇
先抛个小问号:你更想写“能跑的 Demo”,还是“敢上线的产品雏形”?如果是后者,今天这篇就是给你的——从技术栈到落地细节,我把踩坑与取舍都摊开讲,顺手奉上可复制的代码片段与工程化配置。风格嘛,咱就不端着:有点俏皮、有点较真,更有一丢丢“我就要把东西做成”的倔劲儿。
小确认 👇
你提到“下面给出的内容及大纲”,但暂时没看到具体大纲。我先按“Next.js(前端)+ NestJS(后端)+ PostgreSQL(数据库)+ Prisma(ORM)+ Docker(部署)”来写一个可上云的最小可行版本。如果你有指定业务场景或更偏爱的栈(如 Vue/Nuxt、Go/Fiber、MongoDB 等),回我一句,我再按你的喜好微调~
前言:别把“能跑通”当成“能上线”
太多教程止步于“跑起来就算赢”。但真实世界里,鉴权、安全、可观测、脚本化运维 才是区分“玩具”和“产品”的分水岭。本文目标很直接:以最少的复杂度,交付一个能被真实用户访问的全栈应用雏形。我们会覆盖:
- 技术选型的取舍逻辑(不是堆料,而是克制)
- 前后端目录与边界(模块化,而不是一团糊)
- 用户登录注册(JWT + HttpOnly Cookie)
- 数据持久化(Postgres + Prisma 迁移)
- 工程化(ESLint/Prettier、测试、CI)
- 部署(Docker Compose 本地 + 单容器上线思路)
技术选型:为什么是这四位选手?
- Next.js:服务端渲染(SEO 友好)、App Router、服务端 Actions,上手快,生态厚。
- NestJS:结构化后端框架,依赖注入与装饰器让模块边界清晰,写业务快。
- PostgreSQL:范式清晰、类型强、生态稳,改需求不慌。
- Prisma:类型安全、迁移顺滑,让数据库迭代不再“手抖”。
反问一句:你是要“每周都在救火”,还是“稳妥加速迭代”?这套栈的答案,偏向后者。
项目骨架与目录
fullstack-app/
├─ apps/
│ ├─ web/ # Next.js 前端
│ └─ api/ # NestJS 后端
├─ packages/
│ └─ shared/ # 共享类型/工具函数
├─ docker/
│ └─ dev/
│ └─ docker-compose.yml
├─ prisma/ # 统一管理 schema(也可放在 api 内)
│ └─ schema.prisma
├─ .github/workflows/ci.yml
└─ README.md
Monorepo 的好处是共享类型与约定。前后端协作不再“口口相传”,而是“类型即契约”。
数据模型:从用户出发,别一上来造宇宙飞船
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../apps/api/node_modules/@prisma/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
authorId String
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}
运行迁移(在 apps/api 环境里):
npx prisma migrate dev --name init
后端(NestJS):把“边界”写进代码里
安装与脚手架
pnpm dlx @nestjs/cli new api
cd apps/api
pnpm add @prisma/client prisma bcrypt jsonwebtoken cookie-parser
pnpm add -D @types/jsonwebtoken @types/cookie-parser
apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from './modules/auth/auth.module';
import { PostModule } from './modules/post/post.module';
@Module({
imports: [AuthModule, PostModule],
})
export class AppModule {}
Prisma 服务(连接数据库)
apps/api/src/infra/prisma.service.ts
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() { await this.$connect(); }
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => { await app.close(); });
}
}
认证模块(注册 + 登录 + Cookie 注入)
apps/api/src/modules/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import * as bcrypt from 'bcrypt';
import * as jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_only';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService) {}
async signup(email: string, password: string, name?: string) {
const hash = await bcrypt.hash(password, 10);
return this.prisma.user.create({ data: { email, password: hash, name } });
}
async signin(email: string, password: string) {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException('Invalid credentials');
}
const token = jwt.sign({ sub: user.id }, JWT_SECRET, { expiresIn: '7d' });
return { token, user: { id: user.id, email: user.email, name: user.name } };
}
}
apps/api/src/modules/auth/auth.controller.ts
import { Body, Controller, Post, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private auth: AuthService) {}
@Post('signup')
async signup(@Body() body: any) {
const { email, password, name } = body;
const user = await this.auth.signup(email, password, name);
return { id: user.id, email: user.email, name: user.name };
}
@Post('signin')
async signin(@Body() body: any, @Res({ passthrough: true }) res: Response) {
const { email, password } = body;
const result = await this.auth.signin(email, password);
res.cookie('token', result.token, { httpOnly: true, sameSite: 'lax' });
return result.user;
}
}
帖子模块(受保护路由示例)
apps/api/src/modules/post/post.controller.ts
import { Controller, Get, Post as HPost, Body, Req, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma.service';
import * as jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_only';
function userIdFromReq(req: any): string {
const raw = req.cookies?.token;
if (!raw) throw new UnauthorizedException();
const payload = jwt.verify(raw, JWT_SECRET) as any;
return payload.sub as string;
}
@Controller('posts')
export class PostController {
constructor(private prisma: PrismaService) {}
@Get()
async list() {
return this.prisma.post.findMany({ where: { published: true }, include: { author: true } });
}
@HPost()
async create(@Body() body: any, @Req() req: any) {
const uid = userIdFromReq(req);
const { title, content } = body;
return this.prisma.post.create({ data: { title, content, authorId: uid, published: true }});
}
}
小心得:令牌放在 HttpOnly Cookie,前端就拿不到原文,XSS 风险直线下降。配合 CSRF 策略更稳。
前端(Next.js):把交互做“轻”,把数据拿“稳”
初始化
pnpm dlx create-next-app@latest web --ts --app --eslint
cd apps/web
pnpm add axios
共享类型(示例)
packages/shared/types.ts
export type User = { id: string; email: string; name?: string | null };
export type Post = { id: string; title: string; content: string; createdAt: string };
登录页 apps/web/app/signin/page.tsx
'use client';
import { useState } from 'react';
import axios from 'axios';
export default function SignInPage() {
const [email, setEmail] = useState('demo@demo.com');
const [password, setPassword] = useState('123456');
const [msg, setMsg] = useState('');
async function signin() {
try {
const res = await axios.post(
process.env.NEXT_PUBLIC_API_URL + '/auth/signin',
{ email, password },
{ withCredentials: true }
);
setMsg(`Welcome, ${res.data.name || res.data.email}!`);
} catch (e: any) {
setMsg(e?.response?.data?.message ?? 'Login failed');
}
}
return (
<main className="max-w-md mx-auto p-6">
<h1 className="text-2xl font-bold mb-4">Sign In</h1>
<input className="border p-2 w-full mb-2" value={email} onChange={e=>setEmail(e.target.value)} />
<input className="border p-2 w-full mb-2" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
<button className="bg-black text-white px-4 py-2 rounded" onClick={signin}>Sign In</button>
{msg && <p className="mt-3 text-sm text-gray-600">{msg}</p>}
</main>
);
}
帖子列表(服务端获取) apps/web/app/page.tsx
export const dynamic = 'force-dynamic';
type Post = { id: string; title: string; content: string; author: { email: string } };
async function getPosts(): Promise<Post[]> {
const res = await fetch(process.env.NEXT_PUBLIC_API_URL + '/posts', { cache: 'no-store', credentials: 'include' as any });
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
export default async function Home() {
const posts = await getPosts();
return (
<main className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-semibold mb-4">Latest Posts</h1>
<ul className="space-y-4">
{posts.map(p => (
<li key={p.id} className="border p-4 rounded">
<h2 className="text-xl font-medium">{p.title}</h2>
<p className="text-gray-700">{p.content}</p>
<span className="text-xs text-gray-500">by {p.author.email}</span>
</li>
))}
</ul>
</main>
);
}
操作建议:服务端渲染拿列表,用户动作(比如发帖)走客户端,与 Cookie 搭配既稳又快。
Docker 化:本地一把梭,环境不再扯皮
docker/dev/docker-compose.yml
version: '3.9'
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: appdb
ports: ["5432:5432"]
volumes: ["pg_data:/var/lib/postgresql/data"]
api:
build: ../../apps/api
environment:
DATABASE_URL: postgres://app:app@db:5432/appdb
JWT_SECRET: change_me
depends_on: [db]
ports: ["3001:3001"]
web:
build: ../../apps/web
environment:
NEXT_PUBLIC_API_URL: http://localhost:3001
depends_on: [api]
ports: ["3000:3000"]
volumes:
pg_data:
Nest 启动(片段) apps/api/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
app.enableCors({ origin: ['http://localhost:3000'], credentials: true });
await app.listen(3001);
}
bootstrap();
上线时把
origin换成真实域名,JWT_SECRET 用 KMS 或环境注入,别写死!
测试与质量:不写测试就等着周末加班?
后端单测(Jest) apps/api/test/auth.e2e-spec.ts
import request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
describe('Auth flows', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
await app.init();
});
it('signs up and signs in', async () => {
const email = `u${Date.now()}@demo.com`;
await request(app.getHttpServer()).post('/auth/signup').send({ email, password: '123456' }).expect(201);
const res = await request(app.getHttpServer()).post('/auth/signin').send({ email, password: '123456' }).expect(201);
expect(res.body.email).toBe(email);
expect(res.headers['set-cookie']).toBeDefined();
});
afterAll(async () => { await app.close(); });
});
前端校验(简单示意)
加上 ESLint/Prettier 配置,CI 跑 pnpm -r lint && pnpm -r test 即可。
CI(GitHub Actions):让“可用”成为默认
.github/workflows/ci.yml
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm -r lint
- run: pnpm -r test
安全与可观测:最少的“必需品”
- 输入校验:DTO + class-validator(Nest),前端别信用户。
- 日志:pino/winston + 结构化日志;请求 ID 贯穿链路。
- 错误边界:前端 Error Boundary,后端全局异常过滤器。
- 速率限制:登录、发帖接口加 limiter。
- 监控:先接入一个简单的
/healthz与存活探针;上线后再接 APM。
部署攻略(轻量版)
- 构建镜像:前后端分别 Dockerfile(多阶段构建,瘦身)。
- 数据库:托管 Postgres(RDS、Neon、Supabase),免维护。
- 反向代理:Nginx / Caddy,TLS 一把梭(Let’s Encrypt)。
- 环境变量:
.env.production通过平台注入(Vercel + Render +Fly.io都行)。
别忘记数据库迁移:
npx prisma migrate deploy在启动前执行。
进阶思路:把“玩具”拉成“产品中台”
- RBAC/ABAC:角色权限细化,后续扩容时不拆地基。
- 多租户:Postgres schema per tenant 或列级隔离,因规模选路。
- 文件上传:直传对象存储(S3/Cloudflare R2),避免走后端中转。
- 事件驱动:用户行为 -> 事件总线(SQS/NATS)-> 异步处理。
- 灰度/回滚:镜像打标签、数据库变更可回滚的 migration 策略。
常见坑与我私心给的补丁
- 跨域 + Cookie:前端必须
credentials: 'include',后端credentials: true,域名严格匹配。 - Prisma 连接池:Serverless 环境注意池化配置或直上托管连接池。
- JWT 过期:前端拦截 401,跳登录;后端提供 refresh 流程(可选)。
- 时区问题:统一存 UTC,展示再转用户时区。
结语:写能落地的代码,做敢上线的人
技术人最迷人的时刻,不是炫技,而是当你把“想法”变成“可访问的链接”。今天这套最小全栈方案,够稳、够快、也够克制。你完全可以用它把第一个可用版本推上去,然后——快速学习、快速试错、快速进步。别犹豫,先上车,迭代才是王道。
🧧福利赠与你🧧
无论你是计算机专业的学生,还是对编程有兴趣的小伙伴,都建议直接毫无顾忌的学习此专栏「滚雪球学SpringBoot」专栏(全网一个名),bug菌郑重承诺,凡是学习此专栏的同学,均能获取到所需的知识和技能,全网最快速入门SpringBoot,就像滚雪球一样,越滚越大, 无边无际,指数级提升。
最后,如果这篇文章对你有所帮助,帮忙给作者来个一键三连,关注、点赞、收藏,您的支持就是我坚持写作最大的动力。
同时欢迎大家关注公众号:「猿圈奇妙屋」 ,以便学习更多同类型的技术文章,免费白嫖最新BAT互联网公司面试题、4000G pdf电子书籍、简历模板、技术文章Markdown文档等海量资料。
✨️ Who am I?
我是bug菌(全网一个名),CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等社区博客专家,C站博客之星Top30,华为云多年度十佳博主/价值贡献奖,掘金多年度人气作者Top40,掘金等各大社区平台签约作者,51CTO年度博主Top12,掘金/InfoQ/51CTO等社区优质创作者;全网粉丝合计 30w+;更多精彩福利点击这里;硬核微信公众号「猿圈奇妙屋」,欢迎你的加入!免费白嫖最新BAT互联网公司面试真题、4000G PDF电子书籍、简历模板等海量资料,你想要的我都有,关键是你不来拿。

-End-
- 点赞
- 收藏
- 关注作者
评论(0)