MovieVault使用指南:基于Express 5、Prisma 7和JWT的观看列表API实现
MovieVault:一项限时的全栈开发练习,使用Express 5、Prisma 7和JWT实现观看列表API,并附有关于所有权检查、级联效应及错误处理的详细说明。
计时实时编码练习是评估全栈开发人员的常见方式,它能奖励那些能够快速将简短的需求转化为可用且安全的 API 的候选人。本教程以一个名为 MovieVault 的电影目录应用为例,该应用具备个人观看列表功能,并搭配完整的 Express、TypeScript 和 Prisma 后端实现。您将了解数据模型如何表达多对多关系,认证与权限规则如何映射到中间件和控制器中的校验逻辑,以及在时间有限的开发环境下,在产品上线前哪些缺陷值得修复。
MovieVault 需求说明
该应用允许用户浏览电影目录、将影片标题添加到个人观看列表中,并对这两项功能进行管理。所需功能包括:
- 注册与登录。
服务器端使用 Express、TypeScript 和 Prisma;客户端则使用 React、Tailwind 和 ShadCN UI。
基本规则
- 时间限制为280分钟。
- 作品必须是您自己创作的。
- 必须通过
.gitignore文件排除node_modules目录,并需提交.env.example文件。 - Button、Card 和 Input 等核心组件必须使用 ShadCN UI。
- 错误必须在服务器端处理,并至少通过
console.log或提示框在客户端呈现给用户。
设置服务器与数据模型
该服务器是一个用 TypeScript 编写的 Express 项目,ORM 工具为 Prisma。需求要求实现三个模型:User(包含可能是整数或字符串的 id、唯一的 email、password 以及 name)、Movie(包含 id、title、genre、description 和 imageUrl)以及一个观看列表模型(包含 id、userId、movieId、默认为空字符串的 review 字符串以及默认为 1 的整数 rating)。用户与电影通过观看列表表实现多对多关联,且必须从 server/data/movies.json 中的手写 JSON 文件中导入至少五部电影数据。
下面的模式通过显式的连接模型来实现这一功能。有两点值得注意。@@unique([userId, movieId])约束意味着用户不能重复保存同一部电影,同时它还会为Prisma生成一个名为userId_movieId的复合键,供控制器后续用于查询。datasource部分没有url字段:在Prisma 7中,连接字符串是在模式之外(在prisma.config.ts文件中)配置的,客户端则通过驱动适配器进行连接,因此依赖项中会出现@prisma/adapter-pg和pg。完整的配置步骤可参阅在TypeScript Node.js项目中使用PostgreSQL配置Prisma 7。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
password String
watchList WatchList[]
}
model Movie {
id Int @id @default(autoincrement())
title String
genre String
description String
imageUrl String
watchList WatchList[]
}
model WatchList{
id Int @id @default(autoincrement())
review String @default("")
rating Int @default(1)
userId Int
movieId Int
user User @relation(fields: [userId], references: [id] )
movie Movie @relation(fields: [movieId], references: [id] )
@@unique([userId, movieId])
}
客户端配置
前端是一个基于Vite搭建的React应用框架,采用Tailwind CSS和ShadCN UI进行样式设计,并通过React Router实现路由功能。需求规格将每个服务器端开发阶段与对应的客户端界面对应起来:
/login和/register页面由ShadCN的表单组件构建。成功登录后,令牌会被保存,用户将被重定向到/处的控制面板。- 控制面板以ShadCN卡片的形式将电影展示为网格布局。每张卡片都配有“加入观看列表”和“删除电影”按钮,且删除操作必须立即更新网格显示。
/my-watchlist页面列出用户保存的电影,同时显示每部电影的评论和评分信息。- 提供观看列表条目的编辑功能,可通过ShadCN对话框或独立页面实现,一旦更新成功,界面就会立即刷新。
API 接口规范
POST /register用于注册用户;无需身份验证。POST /login会返回 JWT 访问令牌;无需身份验证。GET /movies可列出所有电影;需要身份验证。DELETE /movies/:id用于从目录中删除某部电影;需要身份验证。GET /watchlist可列出当前用户的观看记录,通过 Prisma 的include功能获取电影详细信息;需要身份验证。POST /watchlist/:movieId用于将某部电影添加到当前用户的观看列表中;需要身份验证。PATCH /watchlist/:id可更新评论和评分,无需检查所有者身份;需要身份验证。DELETE /watchlist/:id用于删除某条观看记录;需要身份验证且仅允许所有者操作。
密码必须使用 bcrypt 进行哈希处理。
注册与登录
认证控制器负责处理所有公共接口。register函数在缺少任何字段时会以400状态码拒绝请求,随后使用成本为12的bcrypt盐值对密码进行哈希处理,创建用户账户,并立即生成一个JWT令牌,其中包含用户的id、name和email信息,该令牌一天后过期。login函数则通过邮箱查找用户,使用bcrypt.compare函数比对密码,然后返回相同类型的令牌。这两个处理函数仅返回安全的用户信息,从不暴露哈希值。
import { Request, Response } from "express";
import prisma from "../libs/prisma";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
export const register = async (req: Request, res: Response) => {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({
message: "Semua kolom harus diisi",
});
}
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
const newUser = await prisma.user.create({
data: {
name: name,
email: email,
password: hashedPassword,
},
});
const token = jwt.sign(
{
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(201).json({
message: "User Berhasil di buat",
token: token,
data: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
},
});
} catch (error) {
return res.status(500).json({
message: "gagal membuat user",
error: error,
});
}
};
export const login = async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
const user = await prisma.user.findUnique({
where: { email: email },
});
if (!user) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const isPasswordValid = user
? await bcrypt.compare(password, user.password)
: false;
if (!isPasswordValid) {
return res.status(400).json({
message: "Email/Username/Password salah",
});
}
const token = jwt.sign(
{
id: user.id,
name: user.name,
email: user.email,
},
process.env.JWT_SECRET as string,
{ expiresIn: "1d" },
);
return res.status(200).json({
message: "Login Berhasil",
token: token,
data: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
return res.status(500).json({
message: "Terjadi kesalahan pada server",
error: error,
});
}
};
需要改进的地方:
- 登录时,对于未知邮箱和错误密码的情况只返回一条消息,这样无法得知存在哪些账户。改为返回401状态码会比400更准确地描述失败原因。
error对象发送给客户端,服务器上只需记录该错误并仅返回一条消息。process.env.JWT_SECRET as string虽能让编译阶段通过,但运行时仍会出问题;如果该变量缺失,每次请求都会失败。应在启动时验证所需的环境变量。如需更完善的实现方案,请参阅Node.js API中可用于生产环境的JWT认证实现。
认证中间件
中间件会读取Authorization请求头,将第一个空格之后的内容视为令牌,若该令牌不存在则返回401响应。随后它会使用共享密钥验证该令牌,并将解码后的有效载荷附加到请求中,以便控制器能够读取用户的id。无效或过期的令牌同样会导致401响应。
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
export const authentication = (
req: Request,
res: Response,
next: NextFunction,
) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ message: "No token provided" });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET as string);
(req as any).user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: "Invalid token" });
}
};
(req as any).user这种类型转换虽然可行,但会使得所有使用该方式的控制器放弃类型检查。通过声明合并来扩展Express的Request接口,可以保持req.user的类型安全性。此外,请求头解析功能支持任意协议,而不仅限于Bearer。
项目依赖项
package.json记录了代码编写时的技术栈版本:Express 5、带PostgreSQL适配器的Prisma 7、bcrypt、jsonwebtoken和cors,此外还有TypeScript以及用于实现代码变更后自动重启的ts-node-dev。版本号更新很快,因此在重复使用这些依赖之前请先查看最新发布版本。
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.22.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1",
"prisma": "^7.8.0",
"ts-node-dev": "^2.0.0",
"typescript": "^6.0.3"
}
}
需要注意的是,列表中的任何依赖都不会将.env文件的内容加载到process.env中,因此你需要使用类似dotenv的工具或运行时的环境文件支持功能,才能获取JWT_SECRET的值。
电影相关接口
getMovies仅返回所有电影。deleteMovie会解析路由参数,若ID非数字则返回400错误,若ID不存在则返回404错误,随后执行一个$transaction操作:首先删除所有关联该电影的观看列表记录,再删除电影本身。先删除子记录可避免外键约束违规,而事务确保这两步要么同时成功,要么同时失败。
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /movies - Mendapatkan semua daftar film
export const getMovies = async (req: Request, res: Response) => {
try {
const movies = await prisma.movie.findMany();
return res.status(200).json(movies);
} catch (error) {
console.error("Get Movies Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil data film",
});
}
};
// DELETE /movies/:id - Menghapus film dari katalog utama
export const deleteMovie = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const movieId = parseInt(id, 10);
if (isNaN(movieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film ada
const movie = await prisma.movie.findUnique({
where: { id: movieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Menggunakan Transaction untuk menghapus watchlist terkait terlebih dahulu
// guna menghindari pelanggaran batasan foreign key (kunci asing)
await prisma.$transaction([
prisma.watchList.deleteMany({
where: { movieId: movieId },
}),
prisma.movie.delete({
where: { id: movieId },
}),
]);
return res.status(200).json({
message: "Film berhasil dihapus dari katalog utama",
});
} catch (error) {
console.error("Delete Movie Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus film",
});
}
};
另一种方法是为观看列表的movie关联声明onDelete: Cascade,让数据库自动删除依赖的记录。显式的事务在代码中更易体现;而级联删除则较难被忽略。
观看列表接口
观看列表控制器包含最有趣的规则:
getWatchlist根据令牌中的用户 ID 进行过滤,并使用include: { movie: true },这样每条记录都会附带对应的电影信息。addToWatchlist会验证电影 ID,确认该电影存在,查找复合键userId_movieId以避免重复项并返回 400 错误,随后使用默认的评论和评分创建记录。updateWatchlist落实协作规则:它会检查记录是否存在,但不验证其所有者是谁。如果存在rating字段,则必须将其解析为 1 到 5 之间的整数。Prisma 会忽略保持undefined状态的字段,因此部分更新仅会影响已发送的数据。deleteFromWatchlist会加载相关记录,若其userId与调用者的不同则返回 403 错误,从而确保只有所有者才能删除记录。
import { Request, Response } from "express";
import prisma from "../libs/prisma";
// GET /watchlist - Mengambil semua watchlist milik user yang sedang login
export const getWatchlist = async (req: Request, res: Response) => {
try {
const userId = (req as any).user.id;
const watchlist = await prisma.watchList.findMany({
where: { userId: userId },
include: {
movie: true, // Menyertakan informasi film
},
});
return res.status(200).json(watchlist);
} catch (error) {
console.error("Get Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat mengambil watchlist",
});
}
};
// POST /watchlist/:movieId - Menambahkan film ke watchlist user yang login
export const addToWatchlist = async (req: Request, res: Response) => {
try {
const { movieId } = req.params;
const userId = (req as any).user.id;
const parsedMovieId = parseInt(movieId, 10);
if (isNaN(parsedMovieId)) {
return res.status(400).json({ message: "ID film tidak valid" });
}
// Periksa apakah film tersebut ada di katalog
const movie = await prisma.movie.findUnique({
where: { id: parsedMovieId },
});
if (!movie) {
return res.status(404).json({ message: "Film tidak ditemukan" });
}
// Periksa apakah film sudah ada di watchlist user tersebut
const existingWatchlist = await prisma.watchList.findUnique({
where: {
userId_movieId: {
userId: userId,
movieId: parsedMovieId,
},
},
});
if (existingWatchlist) {
return res.status(400).json({
message: "Film ini sudah ada di dalam daftar watchlist Anda",
});
}
const newWatchlistItem = await prisma.watchList.create({
data: {
userId: userId,
movieId: parsedMovieId,
review: "",
rating: 1,
},
include: {
movie: true,
},
});
return res.status(201).json({
message: "Film berhasil ditambahkan ke watchlist",
data: newWatchlistItem,
});
} catch (error) {
console.error("Add to Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menambahkan watchlist",
});
}
};
// PATCH /watchlist/:id - Memperbarui review dan rating (siapa saja yang login bisa mengubah)
export const updateWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { review, rating } = req.body;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi rating jika dikirimkan
if (rating !== undefined) {
const parsedRating = parseInt(rating, 10);
if (isNaN(parsedRating) || parsedRating < 1 || parsedRating > 5) {
return res.status(400).json({
message: "Rating harus berupa angka antara 1 sampai 5",
});
}
}
const updatedItem = await prisma.watchList.update({
where: { id: watchlistId },
data: {
review: review !== undefined ? review : undefined,
rating: rating !== undefined ? parseInt(rating, 10) : undefined,
},
include: {
movie: true,
},
});
return res.status(200).json({
message: "Watchlist berhasil diperbarui",
data: updatedItem,
});
} catch (error) {
console.error("Update Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat memperbarui watchlist",
});
}
};
// DELETE /watchlist/:id - Menghapus film dari watchlist (hanya bisa oleh pembuatnya/Owner)
export const deleteFromWatchlist = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const userId = (req as any).user.id;
const watchlistId = parseInt(id, 10);
if (isNaN(watchlistId)) {
return res.status(400).json({ message: "ID watchlist tidak valid" });
}
// Periksa apakah item watchlist ada
const watchlistItem = await prisma.watchList.findUnique({
where: { id: watchlistId },
});
if (!watchlistItem) {
return res.status(404).json({ message: "Item watchlist tidak ditemukan" });
}
// Validasi kepemilikan (Owner)
if (watchlistItem.userId !== userId) {
return res.status(403).json({
message: "Anda tidak memiliki akses untuk menghapus item ini dari watchlist",
});
}
await prisma.watchList.delete({
where: { id: watchlistId },
});
return res.status(200).json({
message: "Film berhasil dihapus dari watchlist Anda",
});
} catch (error) {
console.error("Delete Watchlist Error:", error);
return res.status(500).json({
message: "Terjadi kesalahan pada server saat menghapus watchlist",
});
}
};
有两个需要注意的边界情况。重复性检查与插入操作是两个独立的查询,因此同时发起的两个请求都可能通过检查;虽然唯一性约束仍在保护数据,但失败的请求会返回500错误,除非你能捕获到这种违规行为。此外,parseInt的功能较为宽松:"4.7"会被转换为4,而"3abc"则被转换为3,因此使用更严格的检查方式如Number.isInteger(Number(rating))能更可靠地拒绝格式错误的输入。
路由连接
路由层为每个资源对应一个文件,另有一个索引文件。认证相关路由挂载在 /auth 下,而 /movies 和 /watchlist 在挂载时会经过 authentication 中间件处理,因此其后的所有处理函数都会自动受到保护,无需重复进行验证。入口点支持 CORS、解析 JSON 请求体,将所有内容挂载在 /api 下,并在 3000 端口上监听请求。
authRoute.ts
import { Router } from "express";
import { login, register } from "../controllers/authController";
const router = Router();
router.post("/register", register);
router.post("/login", login);
export default router;
index.ts
import { Router } from "express";
import authRoute from "./authRoute";
import movieRoute from "./movieRoute";
import watchlistRoute from "./watchlistRoute";
import { authentication } from "../middlewares/authMiddleware";
const route = Router();
route.use("/auth", authRoute);
route.use("/movies", authentication, movieRoute);
route.use("/watchlist", authentication, watchlistRoute);
export default route;
movieRoute.ts
import { Router } from "express";
import { deleteMovie, getMovies } from "../controllers/movieController";
const router = Router();
router.get("/", getMovies);
router.delete("/:id", deleteMovie);
export default router;
watchlistRoute.ts
import { Router } from "express";
import {
getWatchlist,
addToWatchlist,
updateWatchlist,
deleteFromWatchlist,
} from "../controllers/watchlistController";
const router = Router();
router.get("/", getWatchlist);
router.post("/:movieId", addToWatchlist);
router.patch("/:id", updateWatchlist);
router.delete("/:id", deleteFromWatchlist);
export default router;
isi main index.ts yang selevel dengan src:
import express from "express";
import route from "./routes";
import cors from "cors";
const app = express();
const port = 3000;
app.use(cors());
app.use(express.json());
app.use("/api", route);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
由于存在这些前缀,实际路径为 /api/auth/register、/api/movies 等,而非协议中规定的纯路径。请确保客户端及所有 API 文档使用相同的基础 URL。
关键要点
- 通过带有复合唯一约束的显式关联模型,既能防止数据重复,又能获得便捷的查询键。
req.user的值以及验证环境变量。