회원 접속일과 콘텐츠 활동일을 분리한다
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user