회원 접속일과 콘텐츠 활동일을 분리한다

This commit is contained in:
2026-04-03 13:20:46 +09:00
parent f1756a4ff1
commit 953837137a
10 changed files with 86 additions and 22 deletions

View File

@@ -66,6 +66,7 @@ function mapUserRow(row) {
tierListCount: Number(row.tierlist_count || 0),
followerCount: Number(row.follower_count || 0),
receivedFavoriteCount: Number(row.received_favorite_count || 0),
lastLoginAt: Number(row.last_login_at || 0),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
@@ -284,6 +285,7 @@ async function ensureSchema() {
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
last_login_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
@@ -294,6 +296,12 @@ async function ensureSchema() {
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,
@@ -636,7 +644,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -650,7 +658,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -659,7 +667,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -673,7 +681,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
@@ -683,14 +691,24 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}
async function touchUserLastLoginAt(userId, timestamp = now()) {
const lastLoginAt = Number(timestamp) || now()
const staleBefore = lastLoginAt - 10 * 60 * 1000
await query(
'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
[lastLoginAt, userId, staleBefore]
)
return findUserById(userId)
}
async function updateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
@@ -816,7 +834,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
@@ -849,6 +867,10 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
? isAsc
? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
: 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
: sort === 'lastLogin'
? isAsc
? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
@@ -862,6 +884,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.email_verified,
u.is_admin,
u.avatar_src,
u.last_login_at,
u.created_at,
COUNT(DISTINCT t.id) AS tierlist_count,
COUNT(DISTINCT uf.follower_id) AS follower_count,
@@ -875,7 +898,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
LEFT JOIN user_follows uf ON uf.following_id = u.id
LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
ORDER BY ${orderBy}
`,
params
@@ -2987,6 +3010,7 @@ module.exports = {
findUserById,
findUserProfileById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,

View File

@@ -976,7 +976,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
router.get('/users', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)

View File

@@ -10,6 +10,7 @@ const {
findUserByNickname,
findUserById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
@@ -204,7 +205,8 @@ router.post('/signup', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user), verificationRequired: false })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -227,7 +229,8 @@ router.post('/login', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -240,7 +243,7 @@ router.post('/logout', async (req, res) => {
router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
const user = await touchUserLastLoginAt(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user: await serializeUser(user) })
})
@@ -265,7 +268,8 @@ router.post('/email/verify', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -321,7 +325,8 @@ router.post('/password-reset/confirm', async (req, res) => {
try {
await establishSession(req, verifiedUser)
res.json({ user: await serializeUser(verifiedUser) })
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}