949 字
5 分钟
在 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 服务中加入分页逻辑。
在 MedusaJS 与 Next.js 店面中实现产品评论功能的完整教程
https://sw.rscclub.website/posts/medusajsnextjs/
作者
杨月昌
发布于
2020-11-12
许可协议
CC BY-NC-SA 4.0