🎄 科学と神々株式会社 アドベントカレンダー 2025
Hybrid License System Day 17: ユーザー管理API実装
Admin Service編 (2/5)
📖 はじめに
Day 17では、ユーザー管理API実装を学びます。CRUD操作、検索とフィルタリング、ページネーション、ソート機能を実装しましょう。
🛣️ RESTful API設計
エンドポイント一覧
| Method | Endpoint | 説明 | 必要権限 |
|---|---|---|---|
| GET | /api/admin/users |
ユーザー一覧取得 | users.read |
| GET | /api/admin/users/:userId |
ユーザー詳細取得 | users.read |
| POST | /api/admin/users |
ユーザー作成 | users.write |
| PUT | /api/admin/users/:userId |
ユーザー更新 | users.write |
| DELETE | /api/admin/users/:userId |
ユーザー削除 | users.delete |
| GET | /api/admin/users/search |
ユーザー検索 | users.read |
| PUT | /api/admin/users/:userId/plan |
プラン変更 | users.write |
| PUT | /api/admin/users/:userId/status |
ステータス変更 | users.write |
📊 ユーザー一覧API
ページネーション + フィルタリング + ソート
// admin-service/src/controllers/userController.js
class UserController {
constructor(db) {
this.db = db;
}
/**
* ユーザー一覧取得
* クエリパラメータ:
* - page: ページ番号(デフォルト: 1)
* - pageSize: 1ページあたりの件数(デフォルト: 20)
* - sortBy: ソートフィールド(created_at, email, plan)
* - sortOrder: ソート順序(asc, desc)
* - plan: プランフィルター(free, professional, enterprise)
* - search: 検索キーワード(email部分一致)
*/
async getUsers(req, res) {
try {
const {
page = 1,
pageSize = 20,
sortBy = 'created_at',
sortOrder = 'desc',
plan,
search
} = req.query;
// バリデーション
const validSortFields = ['created_at', 'email', 'plan', 'user_id'];
const validSortOrders = ['asc', 'desc'];
if (!validSortFields.includes(sortBy)) {
return res.status(400).json({
success: false,
error: 'Invalid sortBy field'
});
}
if (!validSortOrders.includes(sortOrder.toLowerCase())) {
return res.status(400).json({
success: false,
error: 'Invalid sortOrder'
});
}
// クエリ構築
let whereClause = '1=1';
const params = [];
// プランフィルター
if (plan) {
whereClause += ' AND plan = ?';
params.push(plan);
}
// 検索フィルター
if (search) {
whereClause += ' AND email LIKE ?';
params.push(`%${search}%`);
}
// 総件数取得
const countStmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM users
WHERE ${whereClause}
`);
const { count: total } = countStmt.get(...params);
// データ取得
const offset = (parseInt(page) - 1) * parseInt(pageSize);
const dataStmt = this.db.prepare(`
SELECT user_id, email, plan, created_at, updated_at
FROM users
WHERE ${whereClause}
ORDER BY ${sortBy} ${sortOrder.toUpperCase()}
LIMIT ? OFFSET ?
`);
const users = dataStmt.all(...params, parseInt(pageSize), offset);
// レスポンス
res.json({
success: true,
data: users,
pagination: {
page: parseInt(page),
pageSize: parseInt(pageSize),
total,
totalPages: Math.ceil(total / parseInt(pageSize))
}
});
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
/**
* ユーザー詳細取得
*/
async getUserById(req, res) {
try {
const { userId } = req.params;
// ユーザー取得
const user = this.db.prepare(`
SELECT user_id, email, plan, created_at, updated_at
FROM users
WHERE user_id = ?
`).get(userId);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// 関連ライセンス取得
const licenses = this.db.prepare(`
SELECT license_id, client_id, status, created_at, expires_at
FROM licenses
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
// 統計情報
const stats = {
totalLicenses: licenses.length,
activeLicenses: licenses.filter(l => l.status === 'active').length,
expiredLicenses: licenses.filter(l => l.status === 'expired').length
};
res.json({
success: true,
data: {
user,
licenses,
stats
}
});
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
}
module.exports = UserController;
➕ ユーザー作成API
class UserController {
/**
* ユーザー作成
*/
async createUser(req, res) {
try {
const { email, password, plan = 'free' } = req.body;
// バリデーション
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'Email and password are required'
});
}
// メールアドレス形式チェック
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
// パスワード強度チェック
if (password.length < 8) {
return res.status(400).json({
success: false,
error: 'Password must be at least 8 characters'
});
}
// プランチェック
const validPlans = ['free', 'professional', 'enterprise'];
if (!validPlans.includes(plan)) {
return res.status(400).json({
success: false,
error: 'Invalid plan'
});
}
// 重複チェック
const existing = this.db.prepare(
'SELECT * FROM users WHERE email = ?'
).get(email);
if (existing) {
return res.status(409).json({
success: false,
error: 'User already exists'
});
}
// パスワードハッシュ化
const bcrypt = require('bcrypt');
const passwordHash = await bcrypt.hash(password, 10);
// ユーザー作成(トランザクション)
const transaction = this.db.transaction(() => {
const userId = `user-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const now = new Date().toISOString();
// ユーザー挿入
this.db.prepare(`
INSERT INTO users (user_id, email, password_hash, plan, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, email, passwordHash, plan, now, now);
// 監査ログ
this.logAudit(req.user.admin_id, 'USER_CREATED', {
userId,
email,
plan
});
return this.db.prepare(
'SELECT user_id, email, plan, created_at FROM users WHERE user_id = ?'
).get(userId);
});
const newUser = transaction();
res.status(201).json({
success: true,
data: newUser
});
} catch (error) {
console.error('Create user error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
/**
* 監査ログ記録
*/
logAudit(adminId, action, details) {
const logId = `log-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO audit_logs (log_id, user_id, action, details, timestamp)
VALUES (?, ?, ?, ?, ?)
`).run(logId, adminId, action, JSON.stringify(details), now);
}
}
✏️ ユーザー更新API
class UserController {
/**
* ユーザー更新
*/
async updateUser(req, res) {
try {
const { userId } = req.params;
const { email, plan } = req.body;
// ユーザー存在確認
const user = this.db.prepare(
'SELECT * FROM users WHERE user_id = ?'
).get(userId);
if (!user) {
return res.status(404).json({
success: false,
error: 'User not found'
});
}
// 更新内容準備
const updates = [];
const params = [];
if (email) {
// メールアドレス形式チェック
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({
success: false,
error: 'Invalid email format'
});
}
// 重複チェック
const existing = this.db.prepare(
'SELECT * FROM users WHERE email = ? AND user_id != ?'
).get(email, userId);
if (existing) {
return res.status(409).json({
success: false,
error: 'Email already in use'
});
}
updates.push('email = ?');
params.push(email);
}
if (plan) {
const validPlans = ['free', 'professional', 'enterprise'];
if (!validPlans.includes(plan)) {
return res.status(400).json({
success: false,
error: 'Invalid plan'
});
}
updates.push('plan = ?');
params.push(plan);
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
error: 'No fields to update'
});
}
// updated_at追加
updates.push('updated_at = ?');
params.push(new Date().toISOString());
// トランザクション
const transaction = this.db.transaction(() => {
// ユーザー更新
this.db.prepare(`
UPDATE users
SET ${updates.join(', ')}
WHERE user_id = ?
`).run(...params, userId);
// 監査ログ
this.logAudit(req.user.admin_id, 'USER_UPDATED', {
userId,
changes: { email, plan }
});
return this.db.prepare(
'SELECT user_id, email, plan, created_at, updated_at FROM users WHERE user_id = ?'
).get(userId);
});
const updatedUser = transaction();
res.json({
success: true,
data: updatedUser
});
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
/**
* プラン変更
*/
async updatePlan(req, res) {
try {
const { userId } = req.params;
const { plan } = req.body;
const validPlans = ['free', 'professional', 'enterprise'];
if (!validPlans.includes(plan)) {
return res.status(400).json({
success: false,
error: 'Invalid plan'
});
}
const transaction = this.db.transaction(() => {
const user = this.db.prepare('SELECT * FROM users WHERE user_id = ?').get(userId);
if (!user) {
throw new Error('User not found');
}
this.db.prepare(`
UPDATE users
SET plan = ?, updated_at = ?
WHERE user_id = ?
`).run(plan, new Date().toISOString(), userId);
this.logAudit(req.user.admin_id, 'PLAN_CHANGED', {
userId,
oldPlan: user.plan,
newPlan: plan
});
return this.db.prepare('SELECT user_id, email, plan FROM users WHERE user_id = ?').get(userId);
});
const updated = transaction();
res.json({
success: true,
data: updated
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
success: false,
error: error.message
});
}
console.error('Update plan error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
}
🗑️ ユーザー削除API
class UserController {
/**
* ユーザー削除(論理削除推奨)
*/
async deleteUser(req, res) {
try {
const { userId } = req.params;
const transaction = this.db.transaction(() => {
const user = this.db.prepare('SELECT * FROM users WHERE user_id = ?').get(userId);
if (!user) {
throw new Error('User not found');
}
// 関連ライセンス取得
const licenses = this.db.prepare(
'SELECT * FROM licenses WHERE user_id = ?'
).all(userId);
// ユーザー削除(CASCADE設定により関連ライセンスも削除)
this.db.prepare('DELETE FROM users WHERE user_id = ?').run(userId);
// 監査ログ
this.logAudit(req.user.admin_id, 'USER_DELETED', {
userId,
email: user.email,
deletedLicenses: licenses.length
});
return {
deletedUser: {
userId: user.user_id,
email: user.email
},
deletedLicenses: licenses.length
};
});
const result = transaction();
res.json({
success: true,
data: result
});
} catch (error) {
if (error.message === 'User not found') {
return res.status(404).json({
success: false,
error: error.message
});
}
console.error('Delete user error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
}
🔍 検索API
class UserController {
/**
* ユーザー検索(複合条件)
*/
async searchUsers(req, res) {
try {
const {
email,
plan,
createdAfter,
createdBefore,
page = 1,
pageSize = 20
} = req.query;
let whereClause = '1=1';
const params = [];
// メール検索(部分一致)
if (email) {
whereClause += ' AND email LIKE ?';
params.push(`%${email}%`);
}
// プランフィルター
if (plan) {
whereClause += ' AND plan = ?';
params.push(plan);
}
// 作成日フィルター
if (createdAfter) {
whereClause += ' AND created_at >= ?';
params.push(createdAfter);
}
if (createdBefore) {
whereClause += ' AND created_at <= ?';
params.push(createdBefore);
}
// 総件数
const countStmt = this.db.prepare(`
SELECT COUNT(*) as count
FROM users
WHERE ${whereClause}
`);
const { count: total } = countStmt.get(...params);
// データ取得
const offset = (parseInt(page) - 1) * parseInt(pageSize);
const dataStmt = this.db.prepare(`
SELECT user_id, email, plan, created_at, updated_at
FROM users
WHERE ${whereClause}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const users = dataStmt.all(...params, parseInt(pageSize), offset);
res.json({
success: true,
data: users,
pagination: {
page: parseInt(page),
pageSize: parseInt(pageSize),
total,
totalPages: Math.ceil(total / parseInt(pageSize))
}
});
} catch (error) {
console.error('Search users error:', error);
res.status(500).json({
success: false,
error: 'Internal Server Error'
});
}
}
}
🧪 テストケース
// admin-service/tests/userController.test.js
const request = require('supertest');
const app = require('../src/app');
describe('User Management API', () => {
let adminToken;
beforeAll(async () => {
// 管理者ログインしてトークン取得
const res = await request(app)
.post('/api/admin/login')
.send({ email: 'admin@example.com', password: 'admin123' });
adminToken = res.body.data.token;
});
describe('GET /api/admin/users', () => {
it('should get users with pagination', async () => {
const res = await request(app)
.get('/api/admin/users?page=1&pageSize=10')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.pagination).toHaveProperty('total');
});
it('should filter users by plan', async () => {
const res = await request(app)
.get('/api/admin/users?plan=professional')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
res.body.data.forEach(user => {
expect(user.plan).toBe('professional');
});
});
});
describe('POST /api/admin/users', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/api/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({
email: 'newuser@example.com',
password: 'SecurePass123',
plan: 'free'
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.email).toBe('newuser@example.com');
});
it('should reject duplicate email', async () => {
const res = await request(app)
.post('/api/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({
email: 'newuser@example.com',
password: 'SecurePass123',
plan: 'free'
});
expect(res.status).toBe(409);
expect(res.body.error).toContain('already exists');
});
});
});
🎯 次のステップ
Day 18では、統計・分析機能を学びます。リアルタイム統計、アクティブユーザー数、ライセンス使用状況、グラフデータ生成について詳しく解説します。
🔗 関連リンク
次回予告: Day 18では、リアルタイムダッシュボードとデータ可視化APIを詳しく解説します!
Copyright © 2025 Gods & Golem, Inc. All rights reserved.