추천 티어표 분리와 관리자 추천 지정 기능 추가

This commit is contained in:
2026-04-03 12:18:04 +09:00
parent 3b9f5f18e0
commit 8a43a2dd2c
12 changed files with 281 additions and 15 deletions

View File

@@ -140,6 +140,9 @@ function mapTierListRow(row) {
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
featuredBy: row.featured_by || '',
showCharacterNames: !!row.show_character_names,
iconSize: Number(row.icon_size || 80),
sourceTierListId: row.source_tierlist_id || '',
@@ -378,6 +381,9 @@ async function ensureSchema() {
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
is_featured TINYINT(1) NOT NULL DEFAULT 0,
featured_at BIGINT NOT NULL DEFAULT 0,
featured_by VARCHAR(64) NOT NULL DEFAULT '',
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
icon_size INT NOT NULL DEFAULT 80,
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
@@ -390,6 +396,7 @@ async function ensureSchema() {
INDEX idx_tierlists_author_id (author_id),
INDEX idx_tierlists_topic_id (topic_id),
INDEX idx_tierlists_public_topic_updated (is_public, topic_id, updated_at),
INDEX idx_tierlists_featured_topic (is_public, is_featured, topic_id, featured_at),
CONSTRAINT fk_tierlists_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlists_topic FOREIGN KEY (topic_id) REFERENCES topics(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
@@ -525,6 +532,18 @@ async function ensureSchema() {
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'is_featured'")
if (!tierListFeaturedColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListFeaturedAtColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_at'")
if (!tierListFeaturedAtColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_at BIGINT NOT NULL DEFAULT 0 AFTER is_featured")
}
const tierListFeaturedByColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'featured_by'")
if (!tierListFeaturedByColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN featured_by VARCHAR(64) NOT NULL DEFAULT '' AFTER featured_at")
}
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
@@ -1997,6 +2016,9 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
t.topic_id,
t.title,
t.thumbnail_src,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
@@ -2006,8 +2028,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
${whereClause}
ORDER BY t.updated_at DESC
LIMIT 50
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
`,
params
)
@@ -2017,6 +2039,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
topicId: row.topic_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
@@ -2029,7 +2053,22 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
const mergedTierLists = applyFavoriteMetaToTierLists(tierLists, favoriteStats)
const featuredTierLists = mergedTierLists
.filter((tierList) => tierList.isFeatured)
.slice()
.sort(
(a, b) =>
Number(b.featuredAt || 0) - Number(a.featuredAt || 0) ||
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0)
)
.slice(0, 16)
return {
featuredTierLists,
tierLists: mergedTierLists.filter((tierList) => !tierList.isFeatured),
}
}
async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited' } = {}) {
@@ -2062,6 +2101,9 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2207,6 +2249,9 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2282,7 +2327,7 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
SELECT t.is_public
SELECT t.is_public, t.is_featured
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
@@ -2293,10 +2338,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const total = rows.length
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length
return {
total,
publicCount,
privateCount: Math.max(0, total - publicCount),
featuredCount,
}
}
@@ -2312,6 +2359,9 @@ async function findTierListById(id, currentUserId = '') {
t.thumbnail_src,
t.description,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
@@ -2520,13 +2570,38 @@ async function deleteTierList(id) {
}
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
const nextUpdatedAt = now()
if (!isPublic) {
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = 0, is_featured = 0, featured_at = 0, featured_by = '', updated_at = ?
WHERE id = ?
`,
[title, description || '', nextUpdatedAt, id]
)
return findTierListById(id)
}
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = ?, updated_at = ?
WHERE id = ?
`,
[title, description || '', isPublic ? 1 : 0, now(), id]
[title, description || '', 1, nextUpdatedAt, id]
)
return findTierListById(id)
}
async function updateTierListFeaturedStatus({ id, isFeatured, adminUserId }) {
await query(
`
UPDATE tierlists
SET is_featured = ?, featured_at = ?, featured_by = ?
WHERE id = ?
`,
[isFeatured ? 1 : 0, isFeatured ? now() : 0, isFeatured ? adminUserId || '' : '', id]
)
return findTierListById(id)
}
@@ -2710,6 +2785,7 @@ module.exports = {
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
favoriteTierList,

View File

@@ -38,6 +38,7 @@ const {
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
updateTierListFeaturedStatus,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
@@ -380,6 +381,25 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
res.json(result)
})
router.patch('/tierlists/:tierListId/featured', requireAdmin, async (req, res) => {
const schema = z.object({
isFeatured: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.tierListId, req.session?.userId || '')
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (parsed.data.isFeatured && !tierList.isPublic) return res.status(400).json({ error: 'public_tierlist_required' })
const updated = await updateTierListFeaturedStatus({
id: tierList.id,
isFeatured: parsed.data.isFeatured,
adminUserId: req.session.userId,
})
res.json({ tierList: updated })
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
res.json({ requests })

View File

@@ -124,8 +124,8 @@ const tierListUpsertSchema = z.object({
router.get('/public', async (req, res) => {
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
const lists = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json({ tierLists: lists })
const result = await listPublicTierLists(topicId, req.session?.userId || '', queryText)
res.json(result)
})
router.get('/me', requireAuth, async (req, res) => {