从 0 到后端闭环:Day2 跑通 Prisma 7 + NestJS + Redis 的实战记录

0 阅读6分钟

Day 2 实操清单(后端闭环日)

目标:将 apps/server 从基础 Fastify 迁移为 NestJS + Fastify,打通 Prisma + PostgreSQL + Redis,并完成 auth/profile/todo/ai-news/stats 核心 API 的最小闭环。


今日验收标准

做到以下 6 点即通过 Day 2:

  • 可以注册与登录
  • 可以读写个人信息
  • 可以读写待办
  • 可以读取 5 条 AI 资讯
  • 可以把 AI 资讯转成待办
  • 可以返回后台统计概览数据

step1. 执行顺序(建议严格按序)

  1. 启动 PostgreSQL + Redis
  2. 新建 packages/database 并接 Prisma
  3. 定义 schema + migration + seed(固定 5 条资讯)
  4. 迁移 apps/server 到 NestJS + Fastify(保留 /health
  5. 接 Redis 服务
  6. 创建业务模块:auth/user/profile/todo/ai-news/stats
  7. 开启 Swagger 并联调接口

step2. 环境准备与命令

3.1 安装依赖(根目录执行)

pnpm add -w @nestjs/common @nestjs/core @nestjs/platform-fastify @nestjs/config @nestjs/swagger class-validator class-transformer reflect-metadata rxjs bcryptjs jsonwebtoken ioredis @prisma/client
pnpm add -Dw prisma ts-node tsconfig-paths @types/jsonwebtoken
pnpm --filter @aitodos/database add @prisma/adapter-pg pg dotenv

3.2 启动数据库与缓存(Docker)

新增 infra/docker/docker-compose.day2.yml

services:
  postgres:
    image: postgres:16
    container_name: aitodos-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: aitodos
    ports:
      - "5432:5432"
    volumes:
      - ai_todos_pg_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    container_name: aitodos-redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes
    volumes:
      - ai_todos_redis_data:/data

volumes:
  ai_todos_pg_data:
  ai_todos_redis_data:

执行:

docker compose -f infra/docker/docker-compose.day2.yml up -d
docker ps

配置说明:

  • Postgres 使用 5432,数据库名 aitodos
  • Redis 使用 6379
  • volume 确保容器重启后数据仍保留

3.3 配置 .env(根目录)

NODE_ENV=development
PORT=3000
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/aitodos?schema=public"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="day2_local_secret"
JWT_EXPIRES_IN="7d"

配置说明:

  • DATABASE_URL:Prisma 数据源
  • REDIS_URL:ioredis 连接地址
  • JWT_SECRET:签发 token 的密钥(开发环境固定值)

step3. Prisma:packages/database

4.1 创建目录

mkdir packages\database
mkdir packages\database\prisma
mkdir packages\database\src

4.2 packages/database/package.json

{
  "name": "@aitodos/database",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "prisma": {
    "seed": "ts-node --esm prisma/seed.ts"
  },
  "scripts": {
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev",
    "prisma:seed": "prisma db seed"
  },
  "dependencies": {
    "@prisma/adapter-pg": "^7.8.0",
    "@prisma/client": "^7.8.0",
    "dotenv": "^16.6.1",
    "pg": "^8.16.3"
  },
  "devDependencies": {
    "prisma": "^7.8.0",
    "ts-node": "^10.9.2"
  }
}

4.3 packages/database/prisma.config.ts(Prisma 7 必需)

import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { defineConfig, env } from "prisma/config";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

dotenv.config({
  path: path.resolve(__dirname, "../../.env"),
});

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
    seed: "ts-node --esm prisma/seed.ts",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

说明:Prisma 7 不再推荐把 datasource.url 写在 schema 中,迁移连接放到 prisma.config.ts

4.4 packages/database/prisma/schema.prisma

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

datasource db {
  provider = "postgresql"
}

enum UserRole {
  USER
  ADMIN
}

enum TodoStatus {
  TODO
  DOING
  DONE
}

model User {
  id           String      @id @default(cuid())
  email        String      @unique
  passwordHash String
  role         UserRole    @default(USER)
  createdAt    DateTime    @default(now())
  updatedAt    DateTime    @updatedAt

  profile      Profile?
  todos        Todo[]
  newsTodoLogs NewsTodoLog[]
}

model Profile {
  id        String   @id @default(cuid())
  userId    String   @unique
  name      String
  interests String[]
  pushTime  String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Todo {
  id          String     @id @default(cuid())
  userId      String
  title       String
  description String?
  source      String     @default("manual")
  status      TodoStatus @default(TODO)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model AiNews {
  id          String   @id @default(cuid())
  title       String
  summary     String
  url         String   @unique
  publishedAt DateTime
  tags        String[]
  createdAt   DateTime @default(now())
}

model NewsTodoLog {
  id        String   @id @default(cuid())
  userId    String
  aiNewsId  String
  todoId    String
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId])
  @@index([aiNewsId])
}

说明:Prisma 7 下,schema.prisma 只保留 provider,不放 url

4.5 packages/database/src/client.ts(Prisma 7 adapter)

import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

dotenv.config({
  path: path.resolve(__dirname, "../../../.env"),
});

const connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("DATABASE_URL is required");
}

export const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString }),
});

4.6 packages/database/prisma/seed.ts(固定 5 条资讯)

import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

dotenv.config({
  path: path.resolve(__dirname, "../../../.env"),
});

const connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("DATABASE_URL is required");
}

const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString }),
});

const aiNews = [
  { title: "OpenAI 发布新一代模型更新", summary: "多模态稳定性提升", url: "https://example.com/news-1", publishedAt: new Date("2026-04-01"), tags: ["LLM", "Multimodal"] },
  { title: "Anthropic 发布 Agent 安全规范", summary: "更细粒度工具权限策略", url: "https://example.com/news-2", publishedAt: new Date("2026-04-02"), tags: ["Agent", "Security"] },
  { title: "Meta 开源视觉模型", summary: "图像理解任务效果提升", url: "https://example.com/news-3", publishedAt: new Date("2026-04-03"), tags: ["Vision", "OpenSource"] },
  { title: "微软扩展 Copilot 生态", summary: "支持更多工程流集成", url: "https://example.com/news-4", publishedAt: new Date("2026-04-04"), tags: ["Copilot", "Productivity"] },
  { title: "社区发布轻量 RAG 框架", summary: "中小团队低成本部署", url: "https://example.com/news-5", publishedAt: new Date("2026-04-05"), tags: ["RAG", "Framework"] }
];

async function main() {
  for (const item of aiNews) {
    await prisma.aiNews.upsert({
      where: { url: item.url },
      update: item,
      create: item
    });
  }
}

main().finally(() => prisma.$disconnect());

4.7 执行 migration + seed

pnpm install
pnpm --filter @aitodos/database prisma:generate
pnpm --filter @aitodos/database prisma:migrate -- --name init_day2
pnpm --filter @aitodos/database prisma:seed

step4. apps/server 迁移到 NestJS + Fastify

5.1 关键目录结构

apps/server/src/
├── main.ts
├── app.module.ts
├── common/
│   ├── prisma.service.ts
│   └── redis.service.ts
└── modules/
    ├── health/
    ├── auth/
    ├── user/
    ├── profile/
    ├── todo/
    ├── ai-news/
    └── stats/

5.2 apps/server/package.json(脚本重点)

{
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/main.js",
    "type-check": "tsc --noEmit"
  },
  "dependencies": {
    "@aitodos/database": "workspace:*",
    "@aitodos/shared": "workspace:*"
  }
}

配置说明:

  • dev 改为监听 src/main.ts
  • 增加 @aitodos/database,服务直接复用 Prisma Client

5.3 apps/server/src/main.ts

import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter({ logger: true })
  );

  app.setGlobalPrefix("api");
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  const config = new DocumentBuilder()
    .setTitle("AiTodos API")
    .setDescription("Day2 backend closed-loop API")
    .setVersion("1.0.0")
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("swagger", app, document);

  await app.listen(process.env.PORT ? Number(process.env.PORT) : 3000, "0.0.0.0");
}

bootstrap();

5.4 保留健康检查 GET /health

// modules/health/health.controller.ts
import { Controller, Get } from "@nestjs/common";

@Controller("health")
export class HealthController {
  @Get()
  getHealth() {
    return { ok: true, service: "server" };
  }
}

说明:虽然全局前缀是 /api,该接口实际路径为 /api/health


6. Redis 接入(最小可用)

apps/server/src/common/redis.service.ts

import { Injectable, OnModuleDestroy } from "@nestjs/common";
import Redis from "ioredis";

@Injectable()
export class RedisService implements OnModuleDestroy {
  private readonly client = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");

  get instance() {
    return this.client;
  }

  async onModuleDestroy() {
    await this.client.quit();
  }
}

配置说明:

  • 先实现连接与释放
  • Day 2 典型用途:token 黑名单、短期缓存、统计临时值

7. 业务模块接口清单(Day 2 最小集)

7.1 Auth

  • POST /api/auth/register
  • POST /api/auth/login
  • GET /api/auth/me

7.2 Profile

  • GET /api/profile
  • PUT /api/profile

7.3 Todo

  • GET /api/todos
  • POST /api/todos
  • PATCH /api/todos/:id
  • DELETE /api/todos/:id

7.4 AI News

  • GET /api/ai-news
  • POST /api/ai-news/:id/add-to-todo

7.5 Stats

  • GET /api/stats/overview

统计返回建议字段:

{
  "addedUserCount": 12,
  "newsToTodoCount": 35,
  "totalAiNewsCount": 120,
  "newsToTodoRate": 0.2917
}

8. 联调命令(Day 2 收尾)

# 1) 启动基础设施
docker compose -f infra/docker/docker-compose.day2.yml up -d

# 2) 执行迁移与 seed
pnpm --filter @aitodos/database prisma:migrate -- --name init_day2
pnpm --filter @aitodos/database prisma:seed

# 3) 启动服务
pnpm --filter @aitodos/server dev

联调地址:

  • 健康检查:http://localhost:3000/api/health
  • Swagger:http://localhost:3000/swagger

9. Day 2 当日产出物清单(打勾版)

  • packages/database 建立完成
  • Prisma migration 文件已生成
  • seed 写入 5 条 AI 资讯
  • apps/server 完成 NestJS + Fastify 迁移
  • /api/health 可访问
  • auth/profile/todo/ai-news/stats 模块接口可调
  • Swagger 可打开并可调试

10. 常见坑位提醒

  • 如果 Prisma 报连接错误,先确认 DATABASE_URL 和容器端口
  • 如果 Redis 连接失败,检查 REDIS_URL 与容器是否启动
  • 如果 Nest 启动时报 reflect-metadata,确认在 main.ts 首行引入
  • 如果 Swagger 空白,确认模块是否被 AppModule 正确引入
  • 如果 pnpm --filter 无法识别包,先检查 pnpm-workspace.yaml 是否包含 packages/*

11. 结论

Day 2 的本质不是“把模块都生成出来”,而是把后端闭环跑通

  • 能存(PostgreSQL)
  • 能查(Prisma + API)
  • 能缓存(Redis)
  • 能联调(Swagger)
  • 能完成“AI 资讯 -> 待办 -> 统计”主链路

只要这个闭环成立,Day 3 前端接入会非常顺畅。