Day 2 实操清单(后端闭环日)
目标:将
apps/server从基础 Fastify 迁移为NestJS + Fastify,打通Prisma + PostgreSQL + Redis,并完成auth/profile/todo/ai-news/stats核心 API 的最小闭环。
今日验收标准
做到以下 6 点即通过 Day 2:
- 可以注册与登录
- 可以读写个人信息
- 可以读写待办
- 可以读取 5 条 AI 资讯
- 可以把 AI 资讯转成待办
- 可以返回后台统计概览数据
step1. 执行顺序(建议严格按序)
- 启动 PostgreSQL + Redis
- 新建
packages/database并接 Prisma - 定义 schema + migration + seed(固定 5 条资讯)
- 迁移
apps/server到 NestJS + Fastify(保留/health) - 接 Redis 服务
- 创建业务模块:
auth/user/profile/todo/ai-news/stats - 开启 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/registerPOST /api/auth/loginGET /api/auth/me
7.2 Profile
GET /api/profilePUT /api/profile
7.3 Todo
GET /api/todosPOST /api/todosPATCH /api/todos/:idDELETE /api/todos/:id
7.4 AI News
GET /api/ai-newsPOST /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 前端接入会非常顺畅。