首页 / 文章

MovieVault使用指南:基于Express 5、Prisma 7和JWT的观看列表API实现

MovieVault:一项限时的全栈开发练习,使用Express 5、Prisma 7和JWT实现观看列表API,并附有关于所有权检查、级联效应及错误处理的详细说明。

发布者:REACTAPP.TOP阅读约 14 分钟3198 词

来源: PRISMA SCHEMA SIMPLE TOUCH (BACKEND)

计时实时编码练习是评估全栈开发人员的常见方式,它能奖励那些能够快速将简短的需求转化为可用且安全的 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更准确地描述失败原因。
  • 注册已存在的邮箱会触发唯一性约束,导致500错误。捕获Prisma的唯一性违规错误并返回409状态码,才能为客户端提供可操作的反馈。
  • 这两个错误处理块都会将原始的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的值以及验证环境变量。