跳转到主要内容
杨月昌的软件博客
返回

在 MedusaJS 与 Next.js 店面中实现产品评论功能的完整教程

核心架构设计

在 Headless 电商架构中,实现产品评论功能涉及三个核心部分的联动:

  1. Medusa Server:定义 ProductReview 实体数据结构,提供 RESTful API 供前后台调用。
  2. Medusa Admin:供商家审核、删除不当评论。
  3. Next.js Storefront:供消费者查看评论并提交心得。

第一步:设置 Medusa 服务端实体

1. 创建实体模型 (Entity)

src/models/product-review.ts 中定义评论的数据结构。

import { BaseEntity, Product, generateEntityId } from "@medusajs/medusa";
import { BeforeInsert, Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Max, Min, IsString, IsNotEmpty } from "class-validator";

@Entity()
export class ProductReview extends BaseEntity {
  @Index()
  @Column({ type: "varchar" })
  product_id: string;

  @ManyToOne(() => Product)
  @JoinColumn({ name: "product_id" })
  product: Product;

  @Column({ type: "varchar" })
  @IsNotEmpty()
  title: string;

  @Column({ type: "varchar" })
  @IsNotEmpty()
  user_name: string;

  @Column({ type: "int" })
  @Min(1)
  @Max(5)
  rating: number;

  @Column({ type: "text" })
  content: string;

  @BeforeInsert()
  private beforeInsert(): void {
    this.id = generateEntityId(this.id, "prev");
  }
}

2. 创建数据库迁移 (Migration)

运行命令生成迁移文件,并在 up 方法中定义表结构。建议使用 TypeORM 的 Table 类以获得更好的类型检查:

import { MigrationInterface, QueryRunner, Table, TableForeignKey } from "typeorm";

export class ProductReview1621234567890 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(new Table({
      name: "product_review",
      columns: [
        { name: "id", type: "varchar", isPrimary: true },
        { name: "product_id", type: "varchar" },
        { name: "user_name", type: "varchar" },
        { name: "title", type: "varchar" },
        { name: "rating", type: "integer" },
        { name: "content", type: "text" },
        { name: "created_at", type: "timestamp with time zone", default: "now()" },
        { name: "updated_at", type: "timestamp with time zone", default: "now()" },
      ]
    }), true);

    await queryRunner.createForeignKey("product_review", new TableForeignKey({
      columnNames: ["product_id"],
      referencedColumnNames: ["id"],
      referencedTableName: "product",
      onDelete: "CASCADE"
    }));
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable("product_review");
  }
}

第二步:编写业务逻辑层 (Service)

src/services/product-review.ts 中封装对数据库的操作逻辑。

import { TransactionBaseService } from "@medusajs/medusa";
import { ProductReviewRepository } from "../repositories/product-review";
import { ProductReview } from "../models/product-review";
import { EntityManager } from "typeorm";

class ProductReviewService extends TransactionBaseService {
  protected productReviewRepository_: typeof ProductReviewRepository;

  constructor(container) {
    super(container);
    this.productReviewRepository_ = container.productReviewRepository;
  }

  async getProductReviews(product_id: string): Promise<ProductReview[]> {
    const reviewRepo = this.activeManager_.withRepository(this.productReviewRepository_);
    return await reviewRepo.find({ where: { product_id } });
  }

  async addProductReview(product_id: string, data: Partial<ProductReview>): Promise<ProductReview> {
    return await this.atomicPhase_(async (manager) => {
      const reviewRepo = manager.withRepository(this.productReviewRepository_);
      const createdReview = reviewRepo.create({ ...data, product_id });
      return await reviewRepo.save(createdReview);
    });
  }
}

export default ProductReviewService;

第三步:暴露 API 路由

src/api/index.ts 中注册 Store 端点,记得配置跨域 (CORS)。

import { Router } from "express";
import { wrapHandler } from "@medusajs/medusa";

export default (rootDirectory, options) => {
  const router = Router();

  // 获取产品评论
  router.get("/store/products/:id/reviews", wrapHandler(async (req, res) => {
    const { id } = req.params;
    const reviewService = req.scope.resolve("productReviewService");
    const reviews = await reviewService.getProductReviews(id);
    res.json({ reviews });
  }));

  // 提交产品评论
  router.post("/store/products/:id/reviews", wrapHandler(async (req, res) => {
    const { id } = req.params;
    const reviewService = req.scope.resolve("productReviewService");
    const review = await reviewService.addProductReview(id, req.body);
    res.status(201).json({ review });
  }));

  return router;
};

第四步:Next.js 店面集成

在店面的产品详情页 (/pages/products/[handle].tsx),我们使用 TanStack Query (React Query) 来处理请求,这比直接使用 useEffect 更优雅。

1. 评论提交表单

import { useForm } from "react-hook-form";
import axios from "axios";

const ReviewForm = ({ productId, refetch }) => {
  const { register, handleSubmit, reset } = useForm();

  const onSubmit = async (data) => {
    try {
      await axios.post(`${process.env.NEXT_PUBLIC_MEDUSA_URL}/store/products/${productId}/reviews`, data);
      reset();
      refetch(); // 刷新评论列表
      alert("Review submitted!");
    } catch (e) {
      console.error("Submission failed", e);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 border p-4 rounded-lg">
      <input {...register("user_name")} placeholder="Your Name" className="w-full border p-2" required />
      <input {...register("title")} placeholder="Review Title" className="w-full border p-2" required />
      <select {...register("rating")} className="w-full border p-2">
        {[5, 4, 3, 2, 1].map(n => <option key={n} value={n}>{n} Stars</option>)}
      </select>
      <textarea {...register("content")} placeholder="Share your thoughts..." className="w-full border p-2" />
      <button type="submit" className="bg-black text-white px-4 py-2 rounded">Submit Review</button>
    </form>
  );
};

2. 展示列表

使用 Tailwind CSS 绘制星级评分,让界面更专业。

const ReviewList = ({ reviews }) => (
  <div className="mt-8 space-y-6">
    <h3 className="text-xl font-bold">Customer Reviews ({reviews.length})</h3>
    {reviews.map((review) => (
      <div key={review.id} className="border-b pb-4">
        <div className="flex items-center mb-2">
          <div className="flex text-yellow-400">
            {Array.from({ length: review.rating }).map((_, i) => (
              <StarIcon key={i} className="h-5 w-5 fill-current" />
            ))}
          </div>
          <span className="ml-2 font-semibold">{review.title}</span>
        </div>
        <p className="text-gray-600 text-sm mb-1">By {review.user_name}</p>
        <p className="text-gray-800">{review.content}</p>
      </div>
    ))}
  </div>
);

总结与避坑指南

  1. CORS 配置:如果在前端遇到跨域错误,请检查 Medusa Server 的 medusa-config.js 中的 store_cors 是否包含了你的店面 URL。
  2. 数据验证:我们在后端使用了 class-validator,前端使用了 react-hook-form。双重验证能确保非法评分(如 6 分)不会进入数据库。
  3. 性能优化:当评论数量增多时,建议在 getProductReviews 服务中加入分页逻辑。


上一篇
深度解构 LSTM:长短期记忆网络的全景与2026年现状
下一篇
提升 Web 性能:基于 Nginx 和 Memcached 的页面缓存方案