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

举报
bug菌 发表于 2025/11/01 22:29:28 2025/11/01
【摘要】 🏆本文收录于「滚雪球学SpringBoot」专栏(全网一个名),手把手带你零基础入门Spring Boot,从入门到就业,助你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!环境说明:Windows 10 + IntelliJ IDEA 2021.3.2 + Jdk 1.8 开篇先抛个小问号:你更想写“能跑的 Demo”,还是“敢上线的产品雏形...

🏆本文收录于「滚雪球学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。

部署攻略(轻量版)

  1. 构建镜像:前后端分别 Dockerfile(多阶段构建,瘦身)。
  2. 数据库:托管 Postgres(RDS、Neon、Supabase),免维护。
  3. 反向代理:Nginx / Caddy,TLS 一把梭(Let’s Encrypt)。
  4. 环境变量:.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-

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。