MongoDB: 最終プロジェクト:レビューシステムの構築
最終更新:2026-08-26
最終プロジェクトは本チュートリアルの知識を統合—ShopHubレビューシステムを構築し、実践能力を検証します。
1. プロジェクト概要
ShopHubレビューシステムは、ECプラットフォーム向けの完全なユーザーレビュー機能です。ユーザーは商品を評価・レビューでき、システムはリアルタイムで平均評価を集計し、APIを通じてレビューデータを提供します。
機能要件:
- ユーザー登録・認証(JWT)
- 商品CRUD(管理者のみ)
- レビューCRUD(ユーザー認証必要)
- 平均評価・レビュー件数のリアルタイム更新
- レビュー一覧(ページネーション + ソート)
- レビュー統計(星評価分布)
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を許可しています。
📖 まとめ
- データモデル:User / Product / Review(関連設計)
- 認証:JWT + bcryptパスワードハッシュ
- 商品API:CRUD + ページネーション + 検索
- レビューAPI:作成 + 一覧 + 統計(星分布)
- リアルタイム平均評価更新
- 権限制御:RBAC(customer / admin)
📝 練習問題
- 基礎問題(⭐): ユーザー登録APIを実装(バリデーション + パスワードハッシュ)。
- 基礎問題(⭐): 商品一覧APIを実装(ページネーション + フィルタ)。
- 応用問題(⭐⭐): レビュー作成APIを実装(重複チェック + 商品統計更新)。
- 応用問題(⭐⭐): レビュー統計APIを実装(星評価分布 + 合計件数)。
- チャレンジ(⭐⭐⭐): 完全なレビューシステムを構築(認証 + 商品 + レビュー + 権限制御)。