目标:将
apps/server从基础 Fastify 迁移为NestJS + Fastify,打通Prisma + PostgreSQL + Redis,并完成auth/profile/todo/ai-news/stats核心 API 的最小闭环。
做到以下 6 点即通过 Day 2:
packages/database 并接 Prismaapps/server 到 NestJS + Fastify(保留 /health)auth/user/profile/todo/ai-news/statspnpm 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新增 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配置说明:
5432,数据库名 aitodos6379.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 的密钥(开发环境固定值)packages/databasemkdir packages\database
mkdir packages\database\prisma
mkdir packages\database\srcpackages/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"
}
}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。
packages/database/prisma/schema.prismagenerator 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。
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 }),
});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());pnpm install
pnpm --filter @aitodos/database prisma:generate
pnpm --filter @aitodos/database prisma:migrate -- --name init_day2
pnpm --filter @aitodos/database prisma:seedapps/server 迁移到 NestJS + Fastifyapps/server/src/
├── main.ts
├── app.module.ts
├── common/
│ ├── prisma.service.ts
│ └── redis.service.ts
└── modules/
├── health/
├── auth/
├── user/
├── profile/
├── todo/
├── ai-news/
└── stats/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 Clientapps/server/src/main.tsimport "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();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。
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();
}
}配置说明:
POST /api/auth/registerPOST /api/auth/loginGET /api/auth/meGET /api/profilePUT /api/profileGET /api/todosPOST /api/todosPATCH /api/todos/:idDELETE /api/todos/:idGET /api/ai-newsPOST /api/ai-news/:id/add-to-todoGET /api/stats/overview统计返回建议字段:
{
"addedUserCount": 12,
"newsToTodoCount": 35,
"totalAiNewsCount": 120,
"newsToTodoRate": 0.2917
}# 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/healthhttp://localhost:3000/swaggerpackages/database 建立完成apps/server 完成 NestJS + Fastify 迁移/api/health 可访问auth/profile/todo/ai-news/stats 模块接口可调DATABASE_URL 和容器端口REDIS_URL 与容器是否启动reflect-metadata,确认在 main.ts 首行引入AppModule 正确引入pnpm --filter 无法识别包,先检查 pnpm-workspace.yaml 是否包含 packages/*Day 2 的本质不是“把模块都生成出来”,而是把后端闭环跑通:
只要这个闭环成立,Day 3 前端接入会非常顺畅。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。