템플릿 slug 구조와 빈 DB 초기화를 정리

This commit is contained in:
2026-04-03 14:36:52 +09:00
parent 30ec2e55b0
commit f506e31549
20 changed files with 422 additions and 290 deletions

View File

@@ -1,6 +1,7 @@
const fs = require('fs/promises')
const path = require('path')
const mysql = require('mysql2/promise')
const { nanoid } = require('nanoid')
const DB_HOST = process.env.DB_HOST || '127.0.0.1'
const DB_PORT = process.env.DB_PORT ? Number(process.env.DB_PORT) : 3306
@@ -9,6 +10,7 @@ const DB_PASSWORD = process.env.DB_PASSWORD || ''
const DB_NAME = process.env.DB_NAME || 'tier_cursor'
const DB_CONNECTION_LIMIT = process.env.DB_CONNECTION_LIMIT ? Number(process.env.DB_CONNECTION_LIMIT) : 10
const FREEFORM_TOPIC_ID = 'freeform'
const TOPIC_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
let poolPromise = null
let initPromise = null
@@ -30,6 +32,20 @@ function serializeJson(value) {
return JSON.stringify(value || [])
}
function normalizeTopicSlug(value) {
return String(value || '').trim().toLowerCase()
}
function assertTopicSlug(slug) {
const normalized = normalizeTopicSlug(slug)
if (!normalized || normalized.length > 120 || !TOPIC_SLUG_PATTERN.test(normalized)) {
const err = new Error('topic_slug_invalid')
err.code = 'TOPIC_SLUG_INVALID'
throw err
}
return normalized
}
function collectUploadSrcsFromItems(items, bucket) {
for (const item of items || []) {
if (typeof item?.src === 'string' && item.src.startsWith('/uploads/')) {
@@ -75,8 +91,10 @@ function mapTopicRow(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug || row.id,
name: row.name,
topicId: row.id,
topicSlug: row.slug || row.id,
topicName: row.name,
thumbnailSrc: row.thumbnail_src || '',
isPublic: row.is_public == null ? true : !!row.is_public,
@@ -138,6 +156,7 @@ function mapTierListRow(row) {
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -169,11 +188,13 @@ function mapTemplateRequestRow(row) {
requesterAvatarSrc: row.requester_avatar_src || '',
sourceTierListId: row.source_tierlist_id || '',
sourceTopicId: row.source_topic_id,
sourceTopicSlug: row.source_topic_slug || row.source_topic_id,
sourceTopicName: row.source_topic_name || '',
sourceTierListTitle: row.title_snapshot || '',
sourceDescription: row.description_snapshot || '',
thumbnailSrc: row.thumbnail_src_snapshot || '',
targetTopicId: row.target_topic_id || '',
targetTopicSlug: row.target_topic_slug || row.target_topic_id || '',
targetTopicName: row.target_topic_name || '',
status: row.status,
items: parseJson(row.items_json, []),
@@ -254,32 +275,6 @@ async function query(sql, params = []) {
return rows
}
async function tableExists(name) {
const rows = await query(
`
SELECT TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
LIMIT 1
`,
[DB_NAME, name]
)
return rows.length > 0
}
async function columnExists(tableName, columnName) {
const rows = await query(
`
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?
LIMIT 1
`,
[DB_NAME, tableName, columnName]
)
return rows.length > 0
}
async function closePool() {
if (!poolPromise) return
const pool = await poolPromise
@@ -305,18 +300,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'")
if (!userEmailVerifiedColumns.length) {
await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash')
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
if (!userLastLoginColumns.length) {
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
@@ -348,25 +331,16 @@ async function ensureSchema() {
await query(`
CREATE TABLE IF NOT EXISTS topics (
id VARCHAR(120) PRIMARY KEY,
slug VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
is_public TINYINT(1) NOT NULL DEFAULT 1,
display_rank INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL
created_at BIGINT NOT NULL,
UNIQUE KEY uq_topics_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicIsPublicColumns = await query("SHOW COLUMNS FROM topics LIKE 'is_public'")
if (!topicIsPublicColumns.length) {
await query('ALTER TABLE topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1 AFTER thumbnail_src')
await query('UPDATE topics SET is_public = 1 WHERE is_public IS NULL')
}
const displayRankColumns = await query("SHOW COLUMNS FROM topics LIKE 'display_rank'")
if (!displayRankColumns.length) {
await query('ALTER TABLE topics ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src')
}
await query(`
CREATE TABLE IF NOT EXISTS topic_items (
id VARCHAR(64) PRIMARY KEY,
@@ -380,11 +354,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const topicItemDisplayOrderColumns = await query("SHOW COLUMNS FROM topic_items LIKE 'display_order'")
if (!topicItemDisplayOrderColumns.length) {
await query('ALTER TABLE topic_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -479,11 +448,6 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const imageAssetLabelColumns = await query("SHOW COLUMNS FROM image_assets LIKE 'label_override'")
if (!imageAssetLabelColumns.length) {
await query("ALTER TABLE image_assets ADD COLUMN label_override VARCHAR(120) NOT NULL DEFAULT '' AFTER src")
}
await query(`
CREATE TABLE IF NOT EXISTS image_optimization_jobs (
id VARCHAR(64) PRIMARY KEY,
@@ -524,125 +488,14 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const templateRequestSourceTierListColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_tierlist_id'")
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
}
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const hasSourceTopicId = await columnExists('template_requests', 'source_topic_id')
if (!hasSourceTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN source_topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const hasTargetTopicId = await columnExists('template_requests', 'target_topic_id')
if (!hasTargetTopicId) {
await query("ALTER TABLE template_requests ADD COLUMN target_topic_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_topic_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_topic_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
}
const templateRequestBoardItemsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'board_items_json'")
if (!templateRequestBoardItemsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN board_items_json LONGTEXT NOT NULL AFTER groups_json")
}
const templateRequestShowNamesColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'show_character_names_snapshot'")
if (!templateRequestShowNamesColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN show_character_names_snapshot TINYINT(1) NOT NULL DEFAULT 0 AFTER board_items_json")
}
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListTopicIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'topic_id'")
if (!tierListTopicIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN topic_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER author_id")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
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")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '' AFTER source_tierlist_id")
}
const tierListSourceAuthorColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_author'")
if (!tierListSourceAuthorColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '' AFTER source_snapshot_title")
}
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name)
INSERT INTO topics (id, slug, name, thumbnail_src, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE slug = VALUES(slug), name = VALUES(name), is_public = VALUES(is_public)
`,
[FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', now()]
[FREEFORM_TOPIC_ID, FREEFORM_TOPIC_ID, '직접 티어표 만들기', '', 1, now()]
)
const countRows = await query('SELECT COUNT(*) AS count FROM topics')
if (Number(countRows[0]?.count || 0) <= 1) {
const createdAt = now()
await query(
`
INSERT INTO topics (id, name, thumbnail_src, created_at)
VALUES
(?, ?, ?, ?),
(?, ?, ?, ?)
`,
['example-topic', '예시 주제', '', createdAt, 'another-topic', '다른 예시 주제', '', createdAt]
)
await query(
`
INSERT INTO topic_items (id, topic_id, src, label, created_at)
VALUES
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?)
`,
[
'img-1',
'example-topic',
'/uploads/seeds/example1.png',
'샘플 1',
createdAt,
'img-2',
'example-topic',
'/uploads/seeds/example2.png',
'샘플 2',
createdAt,
]
)
}
})()
return initPromise
@@ -954,7 +807,7 @@ async function listTopics(currentUserId = '', options = {}) {
const includePrivate = !!options.includePrivate
const rows = await query(
`
SELECT id, name, thumbnail_src, is_public, display_rank, created_at
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
FROM topics
WHERE id <> ?
${includePrivate ? '' : 'AND is_public = 1'}
@@ -978,7 +831,39 @@ async function listTopics(currentUserId = '', options = {}) {
}
async function findTopicById(id) {
const rows = await query('SELECT id, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1', [id])
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE id = ? LIMIT 1',
[id]
)
return mapTopicRow(rows[0])
}
async function findTopicBySlug(slug) {
const normalizedSlug = normalizeTopicSlug(slug)
if (!normalizedSlug) return null
const rows = await query(
'SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at FROM topics WHERE slug = ? LIMIT 1',
[normalizedSlug]
)
return mapTopicRow(rows[0])
}
async function findTopicByIdentifier(topicRef) {
const rawRef = String(topicRef || '').trim()
if (!rawRef) return null
if (rawRef === FREEFORM_TOPIC_ID) return findTopicById(FREEFORM_TOPIC_ID)
const rows = await query(
`
SELECT id, slug, name, thumbnail_src, is_public, display_rank, created_at
FROM topics
WHERE id = ? OR slug = ?
ORDER BY
CASE WHEN id = ? THEN 0 ELSE 1 END ASC
LIMIT 1
`,
[rawRef, normalizeTopicSlug(rawRef), rawRef]
)
return mapTopicRow(rows[0])
}
@@ -1004,23 +889,37 @@ async function findTopicItemById(itemId) {
return mapTopicItemRow(rows[0])
}
async function getTopicDetail(topicId) {
const topic = await findTopicById(topicId)
async function getTopicDetail(topicRef) {
const topic = await findTopicByIdentifier(topicRef)
if (!topic) return null
const items = await listTopicItems(topicId)
const items = await listTopicItems(topic.id)
return { topic, template: topic, items }
}
async function createTopic({ id, name, isPublic = true }) {
await query('INSERT INTO topics (id, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
async function createTopic({ slug, name, isPublic = true }) {
const topicId = nanoid()
const topicSlug = assertTopicSlug(slug)
await query('INSERT INTO topics (id, slug, name, thumbnail_src, is_public, display_rank, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [
topicId,
topicSlug,
name,
'',
isPublic ? 1 : 0,
null,
now(),
])
return findTopicById(id)
return findTopicById(topicId)
}
async function updateTopicMeta(topicId, { slug, name, isPublic }) {
const topicSlug = assertTopicSlug(slug)
await query('UPDATE topics SET slug = ?, name = ?, is_public = ? WHERE id = ?', [
topicSlug,
name,
isPublic ? 1 : 0,
topicId,
])
return findTopicById(topicId)
}
async function updateTopicThumbnail(topicId, thumbnailSrc) {
@@ -2106,8 +2005,15 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
const params = []
let whereClause = 'WHERE t.is_public = 1'
if (topicId) {
const topic = await findTopicByIdentifier(topicId)
if (!topic) {
return {
featuredTierLists: [],
tierLists: [],
}
}
whereClause += ' AND t.topic_id = ?'
params.push(topicId)
params.push(topic.id)
}
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
@@ -2120,6 +2026,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_featured,
@@ -2133,6 +2041,7 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
@@ -2143,6 +2052,8 @@ async function listPublicTierLists(topicId, currentUserId = '', queryText = '')
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isFeatured: !!row.is_featured,
@@ -2202,6 +2113,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2252,6 +2164,8 @@ async function listUserTierLists(userId) {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.created_at,
@@ -2262,6 +2176,7 @@ async function listUserTierLists(userId) {
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
WHERE t.author_id = ?
ORDER BY updated_at DESC
`,
@@ -2271,6 +2186,8 @@ async function listUserTierLists(userId) {
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
@@ -2369,6 +2286,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2395,6 +2313,7 @@ async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryTe
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -2430,6 +2349,7 @@ async function listFollowingTierLists(userId, queryText = '') {
SELECT
t.id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2458,6 +2378,7 @@ async function listFollowingTierLists(userId, queryText = '') {
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
@@ -2536,11 +2457,12 @@ async function listAdminTierLists({
whereParts.push(`(
t.title LIKE ?
OR tp.name LIKE ?
OR tp.slug LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2551,6 +2473,7 @@ async function listAdminTierLists({
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2648,11 +2571,12 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavori
whereParts.push(`(
t.title LIKE ?
OR tp.name LIKE ?
OR tp.slug LIKE ?
OR tp.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
params.push(search, search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
@@ -2693,6 +2617,7 @@ async function findTierListById(id, currentUserId = '') {
t.id,
t.author_id,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
@@ -2831,7 +2756,9 @@ async function findTemplateRequestById(id) {
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.slug AS source_topic_slug,
sg.name AS source_topic_name,
tg.slug AS target_topic_slug,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
@@ -2873,7 +2800,9 @@ async function listAdminTemplateRequests({ status = 'pending', statuses = [] } =
u.nickname,
u.email,
u.avatar_src AS requester_avatar_src,
sg.slug AS source_topic_slug,
sg.name AS source_topic_name,
tg.slug AS target_topic_slug,
tg.name AS target_topic_name
FROM template_requests tr
INNER JOIN users u ON u.id = tr.requester_id
@@ -3084,10 +3013,13 @@ module.exports = {
adminDeleteUser,
listTopics,
findTopicById,
findTopicBySlug,
findTopicByIdentifier,
listTopicItems,
findTopicItemById,
getTopicDetail,
createTopic,
updateTopicMeta,
updateTopicThumbnail,
updateTopicVisibility,
findImageAssetByHash,