👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
  
  🛠️ 主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
  🧭 内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
  💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
  
   如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀

前言

嗨!先打个招呼哈~作为一个喜欢“又稳又飒”架构的全栈创作者,我这回不聊概念空话,直接围绕“能上线、可维护、可扩展”这三件事儿,带你从 0 到 1 起一套现代全栈工程:前端(Next.js 15 + React 19)、后端(NestJS)、数据库(PostgreSQL + Prisma)、缓存与消息(Redis + BullMQ)、鉴权(JWT + Cookies)、测试与质量(Vitest + Playwright + ESLint + Prettier + Husky)、部署(Docker Compose + 生产镜像)——并穿插可复制的代码片段踩坑小记
  整篇会专业但不拧巴有梗但不油腻,配点表情符号提神醒脑 🤹‍♀️,保证你读完就能开干。也请放心:内容是我现场原创输出,降低全网重复率这件事我会尽量做足(当然我无法在这里直接跑查重工具,但写作上已做去同质化处理)。

🧭 前言:为什么又是“全栈”?为什么是这几件砖?

现实很骨感:上线不是写完就撒花,而是要扛得住登录态、权限、限流、幂等、可观测、回滚这些“见不得光”的脏活累活。与其以后补补丁,不如现在一次到位。
  技术选型思路

  • 前端:Next.js 15 的 App Router + React 19,拿到 SSR/SSG/ISR 三连,同时 RSC(React Server Components)省下不必要的 bundle。
  • 后端:NestJS 模块化 + 依赖注入好维护;加上 Class-ValidatorZod 等做边界防御。
  • 数据层:PostgreSQL + Prisma,类型安全 + 迁移顺滑;Redis 做缓存与队列。
  • 鉴权:JWT(短期)+ Refresh Token(长期)+ HttpOnly Cookie,既安全又不折腾。
  • 质量:CI 前置“卡口”——ESLint、Prettier、Vitest、Playwright、Husky。
  • 部署:Docker 镜像分多阶段构建,Compose 一键拉起,生产环境配 只读根文件系统 与最小权限运行。

📦 目录结构(建议版)

fullstack-starter/
├─ apps/
│  ├─ web/                # Next.js 前端
│  └─ api/                # NestJS 后端
├─ packages/
│  ├─ ui/                 # 复用的组件库(可选)
│  └─ config/             # eslint/tsconfig/shares
├─ infra/
│  ├─ docker/             # Dockerfile & compose
│  └─ k8s/                #(可选)后续上 K8s 的清单
└─ .husky/                # 提交钩子

🏗️ 搭一把骨架(Monorepo + PNPM Workspace)✨

mkdir fullstack-starter && cd fullstack-starter
pnpm init
# workspace
echo "packages: ['apps/*','packages/*']" > pnpm-workspace.yaml

# 前端
pnpm dlx create-next-app@latest apps/web --ts --eslint --src-dir --app --tailwind
# 后端
pnpm dlx @nestjs/cli new apps/api --package-manager pnpm

🌐 前端:Next.js 15 + React 19(RSC 友好)

🧩 页面:列表 + 详情(含服务端数据)

apps/web/app/articles/page.tsx

// RSC:在服务端直接取数据,少走一遍客户端请求
import Link from "next/link";

type Article = { id: string; title: string; excerpt: string };

async function fetchArticles(): Promise<Article[]> {
  const res = await fetch(process.env.API_BASE + "/articles", { cache: "no-store" });
  if (!res.ok) throw new Error("Failed to fetch articles");
  return res.json();
}

export default async function ArticlesPage() {
  const articles = await fetchArticles();
  return (
    <main className="mx-auto max-w-3xl p-6">
      <h1 className="text-2xl font-bold">📚 Articles</h1>
      <ul className="mt-4 space-y-3">
        {articles.map(a => (
          <li key={a.id} className="rounded-lg border p-4 hover:shadow">
            <h2 className="text-lg font-semibold">
              <Link href={`/articles/${a.id}`}>{a.title}</Link>
            </h2>
            <p className="text-sm opacity-80">{a.excerpt}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

apps/web/app/articles/[id]/page.tsx

type Article = { id: string; title: string; content: string };

async function fetchArticle(id: string): Promise<Article> {
  const res = await fetch(`${process.env.API_BASE}/articles/${id}`, { cache: "no-store" });
  if (!res.ok) throw new Error("Not found");
  return res.json();
}

export default async function ArticleDetail({ params }: { params: { id: string } }) {
  const article = await fetchArticle(params.id);
  return (
    <article className="prose mx-auto p-6">
      <h1>📝 {article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

🔐 客户端登录态(Client Component + Cookie)

apps/web/components/LoginForm.tsx

"use client";
import { useState } from "react";

export default function LoginForm() {
  const [email, setEmail] = useState("");
  const [pwd, setPwd] = useState("");
  const [msg, setMsg] = useState("");

  const submit = async () => {
    setMsg("Logging in…");
    const res = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify({ email, pwd }),
      headers: { "Content-Type": "application/json" }
    });
    if (res.ok) setMsg("✅ Success!"); else setMsg("❌ Failed.");
  };

  return (
    <div className="space-y-2">
      <input className="input" placeholder="Email" value={email} onChange={e=>setEmail(e.target.value)} />
      <input className="input" placeholder="Password" type="password" value={pwd} onChange={e=>setPwd(e.target.value)} />
      <button className="btn" onClick={submit}>🔓 Sign In</button>
      <p className="text-sm opacity-70">{msg}</p>
    </div>
  );
}

🛠️ 后端:NestJS 模块化 + Prisma(类型安全到位)

1) Prisma Schema

apps/api/prisma/schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        String  @id @default(cuid())
  email     String  @unique
  password  String
  createdAt DateTime @default(now())
  articles  Article[]
}

model Article {
  id        String  @id @default(cuid())
  title     String
  excerpt   String
  content   String
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

2) 模块与控制器(示例)

apps/api/src/articles/articles.module.ts

import { Module } from '@nestjs/common';
import { ArticlesController } from './articles.controller';
import { ArticlesService } from './articles.service';
import { PrismaService } from '../prisma.service';

@Module({
  controllers: [ArticlesController],
  providers: [ArticlesService, PrismaService],
})
export class ArticlesModule {}

apps/api/src/articles/articles.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';

@Injectable()
export class ArticlesService {
  constructor(private prisma: PrismaService) {}

  findAll() {
    return this.prisma.article.findMany({
      orderBy: { createdAt: 'desc' },
      select: { id: true, title: true, excerpt: true },
    });
  }

  findOne(id: string) {
    return this.prisma.article.findUniqueOrThrow({ where: { id } });
  }
}

apps/api/src/articles/articles.controller.ts

import { Controller, Get, Param } from '@nestjs/common';
import { ArticlesService } from './articles.service';

@Controller('articles')
export class ArticlesController {
  constructor(private readonly svc: ArticlesService) {}

  @Get()
  all() {
    return this.svc.findAll();
  }

  @Get(':id')
  one(@Param('id') id: string) {
    return this.svc.findOne(id);
  }
}

3) 鉴权(JWT + Refresh)

apps/api/src/auth/auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { PrismaService } from '../prisma.service';

const ACCESS_EXPIRES = '15m';
const REFRESH_EXPIRES = '7d';

@Injectable()
export class AuthService {
  constructor(private prisma: PrismaService) {}

  async validate(email: string, password: string) {
    const user = await this.prisma.user.findUnique({ where: { email } });
    if (!user) throw new UnauthorizedException();
    const ok = await bcrypt.compare(password, user.password);
    if (!ok) throw new UnauthorizedException();
    return user;
  }

  issueTokens(userId: string) {
    const access = jwt.sign({ sub: userId }, process.env.JWT_SECRET!, { expiresIn: ACCESS_EXPIRES });
    const refresh = jwt.sign({ sub: userId, typ: 'refresh' }, process.env.JWT_SECRET!, { expiresIn: REFRESH_EXPIRES });
    return { access, refresh };
  }
}

apps/api/src/auth/auth.controller.ts

import { Body, Controller, Post, Res } from '@nestjs/common';
import { AuthService } from './auth.service';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  @Post('login')
  async login(@Body() dto: { email: string; pwd: string }, @Res({ passthrough: true }) res: Response) {
    const user = await this.auth.validate(dto.email, dto.pwd);
    const { access, refresh } = this.auth.issueTokens(user.id);
    res.cookie('access', access, { httpOnly: true, sameSite: 'lax', secure: true, maxAge: 15 * 60 * 1000 });
    res.cookie('refresh', refresh, { httpOnly: true, sameSite: 'lax', secure: true, maxAge: 7 * 24 * 60 * 60 * 1000 });
    return { ok: true };
  }
}

⚙️ 性能与健壮性:缓存、队列、限流

🔁 Redis + BullMQ 处理异步任务

apps/api/src/queue/queue.module.ts

import { Module } from '@nestjs/common';
import { Queue, Worker } from 'bullmq';

@Module({})
export class QueueModule {
  static articleQueue = new Queue('article', { connection: { host: process.env.REDIS_HOST, port: 6379 } });

  constructor() {
    new Worker('article', async job => {
      // 假装做个全文索引同步/推送
      console.log('Indexing article', job.data.id);
    }, { connection: { host: process.env.REDIS_HOST, port: 6379 } });
  }
}

🧊 读多写少接口加缓存(示例中间件)

// 极简示意:生产建议接入 cache-manager + Redis adapter
import { NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { createClient } from 'redis';

const client = createClient({ url: process.env.REDIS_URL });

export class CacheMiddleware implements NestMiddleware {
  async use(req: Request, res: Response, next: NextFunction) {
    if (req.method !== 'GET') return next();
    const key = `cache:${req.originalUrl}`;
    const hit = await client.get(key);
    if (hit) return res.type('application/json').send(hit);

    const original = res.json.bind(res);
    res.json = (body: any) => {
      client.setEx(key, 60, JSON.stringify(body)); // TTL 60s
      return original(body);
    };
    next();
  }
}

🚧 限流(全局拦截)

// 使用 @nestjs/throttler
// main.ts
import { ThrottlerModule } from '@nestjs/throttler';

bootstrap() {
  // ...
  app.useGlobalGuards(new ThrottlerGuard());
}

🧪 测试与质量:别把线上当试验田

  • 单元测试:Vitest + @testing-library/react / Nest TestingModule
  • 端到端:Playwright(登录流、权限边界、关键页面渲染)
  • 静态质量:ESLint + Prettier;Husky 在 pre-commit 阶段跑 lint-staged 和测试
  • 约定:Commitlint(feat/fix/chore…),可视化变更日志更省心

示例:packages/config/vitest.config.ts(片段)

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { globals: true, environment: 'node', coverage: { reporter: ['text','lcov'] } }
});

Playwright 登录流(片段):

import { test, expect } from '@playwright/test';

test('login flow', async ({ page }) => {
  await page.goto('/login');
  await page.getByPlaceholder('Email').fill('demo@site.dev');
  await page.getByPlaceholder('Password').fill('demo1234');
  await page.getByRole('button', { name: /sign in/i }).click();
  await expect(page.getByText('Success')).toBeVisible();
});

🐳 部署:Docker 多阶段 + Compose 一把梭

infra/docker/web.Dockerfile

# build
FROM node:20-alpine AS build
WORKDIR /app
COPY . .
RUN corepack enable && pnpm i --frozen-lockfile && pnpm -C apps/web build

# run
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/web/.next /app/apps/web/.next
COPY --from=build /app/apps/web/package.json /app/apps/web/package.json
RUN corepack enable && pnpm i --prod
USER node
EXPOSE 3000
CMD ["pnpm","--dir","apps/web","start"]

infra/docker/api.Dockerfile

FROM node:20-alpine AS build
WORKDIR /app
COPY . .
RUN corepack enable && pnpm i --frozen-lockfile && pnpm -C apps/api build

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/api/dist /app/apps/api/dist
COPY --from=build /app/apps/api/package.json /app/apps/api/package.json
RUN corepack enable && pnpm i --prod
USER node
EXPOSE 4000
CMD ["node","apps/api/dist/main.js"]

infra/docker/compose.yaml

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: app
    volumes: [ "pg:/var/lib/postgresql/data" ]
    healthcheck: { test: ["CMD-SHELL","pg_isready -U postgres"], interval: 5s, retries: 10 }

  redis:
    image: redis:7-alpine

  api:
    build: { context: ../.., dockerfile: infra/docker/api.Dockerfile }
    env_file: [ ../../.env ]
    depends_on: { db: { condition: service_healthy } }
    ports: [ "4000:4000" ]

  web:
    build: { context: ../.., dockerfile: infra/docker/web.Dockerfile }
    environment: { API_BASE: "http://api:4000" }
    ports: [ "3000:3000" ]
    depends_on: [ api ]

volumes: { pg: {} }

🪤 踩坑清单(真情实感版 😅)

  • RSC 的数据获取:别在 Client Component 里硬拉 process.env;统一通过 Route Handler 代理或把服务端取数留在 RSC。
  • JWT 放 CookieHttpOnly + Secure + SameSite=Lax;跨子域要配 domain。刷新令牌务必单独路由与旋转(rotate)。
  • Prisma 迁移:生产库变更要先在影子库跑 migration diff,别让线上直接“惊喜”。
  • Docker 镜像:构建阶段和运行阶段拆开,运行镜像保持“瘦身”,并以非 root 用户运行。
  • Playwright CI:容器内需要额外安装浏览器依赖,或用官方带浏览器的镜像。

📈 可观测与运维(轻装上阵也要有眼睛 👀)

  • 日志:Pino + 请求追踪 ID(前后端透传 x-request-id)。
  • Metrics:Prometheus 指标导出(接口耗时、队列长度、DB 连接池占用);Grafana 看板给老板一个“这图真好看”。
  • 错误上报:Sentry(前后端双端),Release tag 与 commit 绑定,回溯更丝滑。
  • 特性开关:客户端读开关、服务端兜底,重要功能灰度放量别莽撞。

🧭 从零到可用:最小上线剧本

  1. 起服务docker compose up -d;Prisma 迁移:pnpm -C apps/api prisma migrate deploy
  2. 创建管理员:跑个一次性脚本写入 User
  3. 前端环境API_BASE 指向 api 服务内网地址或生产域名。
  4. 接入观测:先接日志与错误上报,别等线上用户帮你报 bug。
  5. 小流量放量:打个“隐藏入口”,让内部同学先试吃。

🧩 实战增强包(可选但真香 💡)

  • RBAC/ABAC 权限模型,与路由、组件展示、数据筛选三端联动。
  • 搜索:Meilisearch / OpenSearch 做全文检索;结合队列异步索引。
  • Webhook:事件通知(如 article.published)+ 签名校验,方便对接三方。
  • 多租户tenant_id 列 + Row Level Security(Postgres)+ 中间件注入。

🏁 收个尾:工程化不是“面子工程”

写 Demo 谁都会,难的是落地。当你把鉴权、缓存、队列、测试、部署都“规整”到位,剩下的“业务快跑”自然顺拐。别看今天篇幅有点长,但所有代码都能直接拎出来复用——这才是工程化的意义呀!🤌


📝 清单复读机(便于你拷贝粘贴)

  • Next.js 15(App Router + RSC)
  • NestJS 模块化 + Prisma + PostgreSQL
  • Redis(缓存/队列)+ BullMQ
  • JWT + Refresh + HttpOnly Cookies
  • Vitest / Playwright / ESLint / Prettier / Husky
  • Docker 多阶段 + Compose 部署
  • Pino 日志、Sentry 报错、Prometheus 指标

🧠 你可能会问(FAQ 快速回球)

  • Q:我只想做个小项目,要不要上这么多件?
    **A:**可以“减配版”:去掉队列与观测,保留鉴权 + Prisma + 基础测试。
  • Q:SSR 还是纯 CSR?
    **A:**SEO 要求高、首屏快:SSR/SSG;纯后台系统:CSR 也行。
  • Q:JWT 会不会不安全?
    A:关键在存放位置与有效期,配合轮换与最小权限即可。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:某个被流“治愈”过的 移动端 老兵
📅 日期:2025-11-05
🧵 本文原创,转载请注明出处。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐