v4.0.9: 관리자 회원 목록 2열·덱 공개/전체 통계 추가

관리자가 회원별 저장 덱 이용 현황을 한눈에 볼 수 있도록 API 집계와 UI를 보강한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 16:51:14 +09:00
parent eb4b35bccc
commit bc30356c8b
8 changed files with 83 additions and 23 deletions

View File

@@ -11,6 +11,41 @@ import {
} from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
* 회원 목록에 저장 덱 수(공개/전체) 부여
* @param {import('better-sqlite3').Database} db
* @param {Array<{ id: number }>} users
* @returns {Array<object>}
*/
function attachUserDeckCounts(db, users) {
if (!users.length) return users;
const placeholders = users.map(() => '?').join(',');
const ids = users.map((u) => u.id);
const counts = db
.prepare(
`
SELECT user_id AS userId,
COUNT(*) AS deckTotal,
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic
FROM decks
WHERE share_type = 'user' AND user_id IN (${placeholders})
GROUP BY user_id
`,
)
.all(...ids);
const countByUser = new Map(counts.map((row) => [row.userId, row]));
return users.map((user) => {
const stats = countByUser.get(user.id);
return {
...user,
deckPublic: stats?.deckPublic ?? 0,
deckTotal: stats?.deckTotal ?? 0,
};
});
}
/**
* 관리자 라우트
* @param {import('better-sqlite3').Database} db
@@ -49,10 +84,13 @@ export function createAdminRouter(db) {
}
res.json({
users: rows.map((u) => ({
...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
users: attachUserDeckCounts(
db,
rows.map((u) => ({
...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
),
total,
page: Number(page),
limit: Number(limit),
@@ -70,9 +108,14 @@ export function createAdminRouter(db) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
const deckTotal = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'",
).get(user.id).c;
const deckPublic = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1",
).get(user.id).c;
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
res.json({ user, deckCount });
res.json({ user, deckCount: deckTotal, deckPublic, deckTotal });
});
router.put('/users/:id', (req, res) => {