MongoDB: 最終プロジェクト:レビューシステムの構築

最終更新:2026-08-26

最終プロジェクトは本チュートリアルの知識を統合—ShopHubレビューシステムを構築し、実践能力を検証します。

1. プロジェクト概要

ShopHubレビューシステムは、ECプラットフォーム向けの完全なユーザーレビュー機能です。ユーザーは商品を評価・レビューでき、システムはリアルタイムで平均評価を集計し、APIを通じてレビューデータを提供します。

機能要件:


100%
graph TB
    subgraph "クライアント"
        Web[Webフロントエンド]
    end

    subgraph "APIサーバー"
        Auth[認証API<br/>/api/auth]
        Products[商品API<br/>/api/products]
        Reviews[レビューAPI<br/>/api/reviews]
    end

    subgraph "MongoDB"
        Users[(users<br/>コレクション)]
        Products_coll[(products<br/>コレクション)]
        Reviews_coll[(reviews<br/>コレクション)]
    end

    Web --> Auth
    Web --> Products
    Web --> Reviews

    Auth --> Users
    Products --> Products_coll
    Reviews --> Reviews_coll
    Reviews --> Users
    Reviews --> Products_coll

    style Reviews fill:#d4edda

2. データモデル設計

(1) Userモデル

JAVASCRIPT
// models/User.js
const mongoose = require('mongoose');

const UserSchema = new mongoose.Schema({
  email: {
    type: String,
    required: [true, 'メールアドレスは必須です'],
    unique: true,
    lowercase: true,
    trim: true,
    match: [/^\S+@\S+\.\S+$/, '無効なメール形式です']
  },
  username: {
    type: String,
    required: [true, 'ユーザー名は必須です'],
    unique: true,
    minlength: 3,
    maxlength: 30
  },
  passwordHash: {
    type: String,
    required: true,
    select: false
  },
  role: {
    type: String,
    enum: ['customer', 'admin'],
    default: 'customer'
  },
  isActive: {
    type: Boolean,
    default: true
  }
}, {
  timestamps: true
});

UserSchema.index({ email: 1 }, { unique: true });
UserSchema.index({ username: 1 }, { unique: true });

module.exports = mongoose.model('User', UserSchema);

(2) Productモデル

JAVASCRIPT
// models/Product.js
const mongoose = require('mongoose');

const ProductSchema = new mongoose.Schema({
  sku: {
    type: String,
    required: true,
    unique: true
  },
  title: {
    type: String,
    required: true,
    maxlength: 200
  },
  description: {
    type: String,
    maxlength: 5000
  },
  price: {
    type: Number,
    required: true,
    min: 0
  },
  category: {
    type: String,
    required: true,
    enum: ['Electronics', 'Books', 'Clothing', 'Home', 'Sports']
  },
  stock: {
    type: Number,
    default: 0,
    min: 0
  },
  // 平均評価とレビュー件数(リアルタイム更新)
  avgRating: {
    type: Number,
    default: 0,
    min: 0,
    max: 5
  },
  reviewCount: {
    type: Number,
    default: 0
  },
  isActive: {
    type: Boolean,
    default: true
  }
}, {
  timestamps: true
});

// テキスト検索インデックス
ProductSchema.index({ title: 'text', description: 'text' });
// カテゴリ + 価格でソート
ProductSchema.index({ category: 1, price: -1 });

module.exports = mongoose.model('Product', ProductSchema);

(3) Reviewモデル

JAVASCRIPT
// models/Review.js
const mongoose = require('mongoose');

const ReviewSchema = new mongoose.Schema({
  productId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Product',
    required: true
  },
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  rating: {
    type: Number,
    required: true,
    min: 1,
    max: 5
  },
  title: {
    type: String,
    required: true,
    maxlength: 100
  },
  content: {
    type: String,
    required: true,
    minlength: 10,
    maxlength: 2000
  },
  isApproved: {
    type: Boolean,
    default: true
  },
  helpfulCount: {
    type: Number,
    default: 0
  }
}, {
  timestamps: true
});

// ユーザーは同一商品に1件のみレビュー可能
ReviewSchema.index({ productId: 1, userId: 1 }, { unique: true });
// 商品でソート
ReviewSchema.index({ productId: 1, createdAt: -1 });

module.exports = mongoose.model('Review', ReviewSchema);


3. APIエンドポイント設計

エンドポイント メソッド 説明 権限
/api/auth/register POST ユーザー登録 なし
/api/auth/login POST ログイン なし
/api/products GET 商品一覧 なし
/api/products/:id GET 商品詳細 なし
/api/products POST 商品作成 admin
/api/products/:id PUT 商品更新 admin
/api/products/:id DELETE 商品削除 admin
/api/products/:id/reviews GET 商品レビュー一覧 なし
/api/reviews POST レビュー作成 customer
/api/reviews/:id PUT レビュー更新 所有者
/api/reviews/:id DELETE レビュー削除 所有者またはadmin


4. 認証API実装

(1) ユーザー登録

JAVASCRIPT
// controllers/authController.js
const User = require('../models/User');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

exports.register = async (req, res) => {
  const { email, username, password } = req.body;

  // 既存ユーザーをチェック
  const existingUser = await User.findOne({
    $or: [{ email }, { username }]
  });

  if (existingUser) {
    return res.status(409).json({
      error: existingUser.email === email
        ? 'このメールアドレスは既に使用されています'
        : 'このユーザー名は既に使用されています'
    });
  }

  // パスワードをハッシュ化
  const passwordHash = await bcrypt.hash(password, 10);

  // ユーザー作成
  const user = await User.create({
    email,
    username,
    passwordHash
  });

  // JWTトークン生成
  const token = jwt.sign(
    { id: user._id, role: user.role, username: user.username },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  res.status(201).json({
    success: true,
    data: {
      user: {
        id: user._id,
        email: user.email,
        username: user.username,
        role: user.role
      },
      token
    }
  });
};

(2) ログイン

JAVASCRIPT
exports.login = async (req, res) => {
  const { email, password } = req.body;

  // ユーザー検索(passwordHash含む)
  const user = await User.findOne({ email }).select('+passwordHash');

  if (!user) {
    return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
  }

  // パスワード検証
  const isValid = await bcrypt.compare(password, user.passwordHash);

  if (!isValid) {
    return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
  }

  // JWTトークン生成
  const token = jwt.sign(
    { id: user._id, role: user.role, username: user.username },
    process.env.JWT_SECRET,
    { expiresIn: '7d' }
  );

  res.json({
    success: true,
    data: {
      user: {
        id: user._id,
        email: user.email,
        username: user.username,
        role: user.role
      },
      token
    }
  });
};


5. 商品API実装

(1) 商品一覧

JAVASCRIPT
// controllers/productController.js
const Product = require('../models/Product');

exports.listProducts = async (req, res) => {
  const {
    page = 1,
    limit = 20,
    category,
    minPrice,
    maxPrice,
    search,
    sort = 'createdAt',
    order = 'desc'
  } = req.query;

  const query = { isActive: true };

  if (category) query.category = category;
  if (minPrice || maxPrice) {
    query.price = {};
    if (minPrice) query.price.$gte = +minPrice;
    if (maxPrice) query.price.$lte = +maxPrice;
  }
  if (search) query.$text = { $search: search };

  const sortOption = {};
  sortOption[sort] = order === 'desc' ? -1 : 1;

  const [products, total] = await Promise.all([
    Product.find(query)
      .select('sku title price avgRating reviewCount thumbnail category')
      .sort(sortOption)
      .limit(+limit)
      .skip((+page - 1) * +limit)
      .lean(),
    Product.countDocuments(query)
  ]);

  res.json({
    success: true,
    data: products,
    meta: {
      page: +page,
      limit: +limit,
      total,
      pages: Math.ceil(total / +limit)
    }
  });
};

(2) 商品作成(管理者のみ)

JAVASCRIPT
exports.createProduct = async (req, res) => {
  const { sku, title, description, price, category, stock } = req.body;

  // SKU重複チェック
  const existing = await Product.findOne({ sku });
  if (existing) {
    return res.status(409).json({ error: `SKU ${sku}は既に存在します` });
  }

  const product = await Product.create({
    sku,
    title,
    description,
    price,
    category,
    stock
  });

  res.status(201).json({ success: true, data: product });
};


6. レビューAPI実装

(1) レビュー作成

JAVASCRIPT
// controllers/reviewController.js
const Review = require('../models/Review');
const Product = require('../models/Product');
const mongoose = require('mongoose');

exports.createReview = async (req, res) => {
  const { productId } = req.params;
  const { rating, title, content } = req.body;
  const userId = req.user.id;

  // 商品存在確認
  const product = await Product.findById(productId);
  if (!product) {
    return res.status(404).json({ error: '商品が見つかりません' });
  }

  // 既存レビューチェック
  const existingReview = await Review.findOne({ productId, userId });
  if (existingReview) {
    return res.status(409).json({ error: 'この商品は既にレビュー済みです' });
  }

  // レビュー作成
  const review = await Review.create({
    productId,
    userId,
    rating,
    title,
    content
  });

  // 商品の平均評価と件数を更新
  await updateProductStats(productId);

  res.status(201).json({ success: true, data: review });
};

// 商品統計更新ヘルパー関数
async function updateProductStats(productId) {
  const stats = await Review.aggregate([
    { $match: { productId: mongoose.Types.ObjectId(productId), isApproved: true } },
    {
      $group: {
        _id: '$productId',
        avgRating: { $avg: '$rating' },
        count: { $sum: 1 }
      }
    }
  ]);

  if (stats.length > 0) {
    await Product.findByIdAndUpdate(productId, {
      avgRating: Math.round(stats[0].avgRating * 10) / 10,
      reviewCount: stats[0].count
    });
  } else {
    await Product.findByIdAndUpdate(productId, {
      avgRating: 0,
      reviewCount: 0
    });
  }
}

(2) レビュー一覧

JAVASCRIPT
exports.listReviews = async (req, res) => {
  const { productId } = req.params;
  const { page = 1, limit = 10, sort = 'createdAt', order = 'desc' } = req.query;

  const sortOption = {};
  sortOption[sort] = order === 'desc' ? -1 : 1;

  const [reviews, total] = await Promise.all([
    Review.find({ productId, isApproved: true })
      .populate('userId', 'username')
      .sort(sortOption)
      .limit(+limit)
      .skip((+page - 1) * +limit)
      .lean(),
    Review.countDocuments({ productId, isApproved: true })
  ]);

  res.json({
    success: true,
    data: reviews,
    meta: {
      page: +page,
      limit: +limit,
      total,
      pages: Math.ceil(total / +limit)
    }
  });
};

(3) レビュー統計(星評価分布)

JAVASCRIPT
exports.getReviewStats = async (req, res) => {
  const { productId } = req.params;

  const distribution = await Review.aggregate([
    { $match: { productId: mongoose.Types.ObjectId(productId), isApproved: true } },
    {
      $group: {
        _id: '$rating',
        count: { $sum: 1 }
      }
    },
    { $sort: { _id: -1 } }
  ]);

  // 全スターを初期化(0件の星も含める)
  const stats = {
    5: 0,
    4: 0,
    3: 0,
    2: 0,
    1: 0
  };

  distribution.forEach(item => {
    stats[item._id] = item.count;
  });

  const total = Object.values(stats).reduce((a, b) => a + b, 0);

  res.json({
    success: true,
    data: {
      distribution: stats,
      total
    }
  });
};


7. 認証ミドルウェア

JAVASCRIPT
// middlewares/auth.js
const jwt = require('jsonwebtoken');

exports.authenticate = (req, res, next) => {
  const authHeader = req.header('Authorization');

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: '認証が必要です' });
  }

  const token = authHeader.replace('Bearer ', '');

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ error: '無効なトークンです' });
  }
};

exports.authorize = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'この操作を行う権限がありません' });
    }
    next();
  };
};


8. ルーティング設定

JAVASCRIPT
// routes/index.js
const express = require('express');
const router = express.Router();

const authController = require('../controllers/authController');
const productController = require('../controllers/productController');
const reviewController = require('../controllers/reviewController');
const { authenticate, authorize } = require('../middlewares/auth');

// 認証ルート
router.post('/auth/register', authController.register);
router.post('/auth/login', authController.login);

// 商品ルート
router.get('/products', productController.listProducts);
router.get('/products/:id', productController.getProduct);
router.post('/products', authenticate, authorize('admin'), productController.createProduct);
router.put('/products/:id', authenticate, authorize('admin'), productController.updateProduct);
router.delete('/products/:id', authenticate, authorize('admin'), productController.deleteProduct);

// レビュールート
router.get('/products/:id/reviews', reviewController.listReviews);
router.get('/products/:id/reviews/stats', reviewController.getReviewStats);
router.post('/products/:id/reviews', authenticate, reviewController.createReview);
router.put('/reviews/:id', authenticate, reviewController.updateReview);
router.delete('/reviews/:id', authenticate, reviewController.deleteReview);

module.exports = router;


9. プロジェクト実行

▶ サンプル 1:ユーザー登録と認証のテスト(難易度 ⭐)

JAVASCRIPT
// ユーザー登録のテスト
const testRegister = async () => {
  const response = await fetch('http://localhost:3000/api/auth/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: 'alice@example.com',
      username: 'alice',
      password: 'Password123'
    })
  });

  const data = await response.json();
  console.log('登録結果:', data);
  return data.data.token;  // JWTトークンを返す
};

// ログインのテスト
const testLogin = async () => {
  const response = await fetch('http://localhost:3000/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: 'alice@example.com',
      password: 'Password123'
    })
  });

  const data = await response.json();
  console.log('ログイン結果:', data);
  return data.data.token;
};

// 実行
testRegister().then(token => {
  console.log('JWTトークン:', token);
});

出力:

TEXT 📖 参照専用
ユーザー登録APIがメール・ユーザー名の重複チェックを行い、bcryptでパスワードをハッシュ化。JWTトークンを返却。

▶ サンプル 2:商品CRUDのテスト(難易度 ⭐⭐)

JAVASCRIPT
// 商品作成(管理者トークン必要)
const testCreateProduct = async (adminToken) => {
  const response = await fetch('http://localhost:3000/api/products', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${adminToken}`
    },
    body: JSON.stringify({
      sku: 'PHONE-001',
      title: 'Smartphone X',
      price: 599,
      category: 'Electronics',
      stock: 50
    })
  });

  const data = await response.json();
  console.log('商品作成:', data);
  return data.data._id;
};

// 商品一覧取得
const testListProducts = async () => {
  const response = await fetch('http://localhost:3000/api/products?category=Electronics&page=1&limit=10');
  const data = await response.json();
  console.log('商品一覧:', data);
  return data;
};

// テスト実行
testCreateProduct('admin_jwt_token_here');
testListProducts();

出力:

TEXT 📖 参照専用
管理者のみ商品作成可能。商品一覧はページネーション・フィルタに対応。avgRatingとreviewCountがリアルタイム更新。

▶ サンプル 3:レビューシステム完全テスト(難易度 ⭐⭐⭐)

JAVASCRIPT
// レビュー作成の完全なテストフロー
const testReviewSystem = async () => {
  // 1. ユーザー登録
  const registerRes = await fetch('http://localhost:3000/api/auth/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: 'bob@example.com',
      username: 'bob',
      password: 'Password123'
    })
  });
  const { data: { token } } = await registerRes.json();

  // 2. 商品一覧から商品IDを取得
  const productsRes = await fetch('http://localhost:3000/api/products');
  const { data: products } = await productsRes.json();
  const productId = products[0]._id;

  // 3. レビュー作成
  const reviewRes = await fetch(`http://localhost:3000/api/products/${productId}/reviews`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify({
      rating: 5,
      title: '最高の製品です!',
      content: '期待以上の品質でした。配送も早く、梱包も丁寧でした。おすすめします。'
    })
  });
  const review = await reviewRes.json();
  console.log('レビュー作成:', review);

  // 4. レビュー一覧取得
  const reviewsRes = await fetch(`http://localhost:3000/api/products/${productId}/reviews`);
  const reviews = await reviewsRes.json();
  console.log('レビュー一覧:', reviews);

  // 5. レビュー統計(星評価分布)
  const statsRes = await fetch(`http://localhost:3000/api/products/${productId}/reviews/stats`);
  const stats = await statsRes.json();
  console.log('レビュー統計:', stats);

  // 6. 商品の平均評価更新を確認
  const productRes = await fetch(`http://localhost:3000/api/products/${productId}`);
  const product = await productRes.json();
  console.log('商品の平均評価:', product.data.avgRating, 'レビュー件数:', product.data.reviewCount);
};

// テスト実行
testReviewSystem();

出力:

TEXT 📖 参照専用
レビューシステム完全フロー:ユーザー登録→商品取得→レビュー作成→一覧取得→統計確認→商品の平均評価更新確認。productId + userIdのユニーク制約で重複レビューを防止。

BASH
# === プロジェクト構造 ===
# shophub-reviews/
# ├── package.json
# ├── .env
# ├── src/
# │   ├── app.js
# │   ├── config/
# │   │   └── db.js
# │   ├── models/
# │   │   ├── User.js
# │   │   ├── Product.js
# │   │   └── Review.js
# │   ├── controllers/
# │   │   ├── authController.js
# │   │   ├── productController.js
# │   │   └── reviewController.js
# │   ├── middlewares/
# │   │   └── auth.js
# │   └── routes/
# │       └── index.js

# === インストールと起動 ===
npm install express mongoose bcrypt jsonwebtoken joi dotenv
npm run dev

# === テスト ===
# ユーザー登録
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","username":"alice","password":"Password123"}'

# ログイン
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"Password123"}'

# 商品作成(管理者トークン必要)
curl -X POST http://localhost:3000/api/products \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{"sku":"PHONE-001","title":"Smartphone X","price":599,"category":"Electronics","stock":50}'

# レビュー作成
curl -X POST http://localhost:3000/api/products/<product_id>/reviews \
  -H "Authorization: Bearer <user_token>" \
  -H "Content-Type: application/json" \
  -d '{"rating":5,"title":"最高!","content":"期待以上の品質でした。おすすめです。"}'

# レビュー一覧
curl http://localhost:3000/api/products/<product_id>/reviews

❓ よくある質問

Q ユーザーが同一商品に複数のレビューを投稿できる?
A いいえ。productId + userIdの複合ユニークインデックスで防止しています。
Q 平均評価はいつ更新される?
A レビュー作成・更新・削除時にupdateProductStats()が呼ばれ、リアルタイムで更新されます。
Q 管理者は他のユーザーのレビューを削除できる?
A はい。権限チェックで所有者またはadminを許可しています。

📖 まとめ


📝 練習問題

  1. 基礎問題(⭐): ユーザー登録APIを実装(バリデーション + パスワードハッシュ)。
  2. 基礎問題(⭐): 商品一覧APIを実装(ページネーション + フィルタ)。
  3. 応用問題(⭐⭐): レビュー作成APIを実装(重複チェック + 商品統計更新)。
  4. 応用問題(⭐⭐): レビュー統計APIを実装(星評価分布 + 合計件数)。
  5. チャレンジ(⭐⭐⭐): 完全なレビューシステムを構築(認証 + 商品 + レビュー + 権限制御)。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%