feat: 迭代三大模块 + 全系统 Bug 修复

新功能:
- 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计
- 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面
- 管理员后台:数据看板 + 用户管理 + 发布公告

Bug 修复:
- 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id
- 群组视图添加 t.group_id 过滤,防止私人数据泄露
- 通知列表添加群组通知条件
- 通知标记已读添加错误处理
- 管理员 API 添加 affectedRows 检查
- 筛选面板金额为 0 时正确回填
- profile onShow 补充 userStore.fetchUserInfo
- 全系统样式修复(overflow/box-sizing)
This commit is contained in:
wangxiaogang
2026-06-06 17:55:29 +08:00
parent bc6eca1e9b
commit 83571de723
35 changed files with 2255 additions and 37 deletions

View File

@@ -11,6 +11,14 @@ async function columnExists(conn: mysql.Connection, table: string, column: strin
return (rows as any[])[0].cnt > 0
}
async function tableExists(conn: mysql.Connection, table: string): Promise<boolean> {
const [rows] = await conn.query(
`SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
[table]
)
return (rows as any[])[0].cnt > 0
}
async function runMigrations(conn: mysql.Connection) {
console.log('[DB] Checking migrations...')
@@ -83,6 +91,49 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
console.log('[DB] Groups migration complete')
}
// users 表添加 role管理员角色
const hasRole = await columnExists(conn, 'users', 'role')
if (!hasRole) {
console.log('[DB] Migrating: adding role to users')
await conn.query("ALTER TABLE users ADD COLUMN role ENUM('user', 'admin') DEFAULT 'user' AFTER avatar_url")
}
// 通知表
const hasNotifications = await tableExists(conn, 'notifications')
if (!hasNotifications) {
console.log('[DB] Migrating: creating notifications table')
await conn.query(`
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告非NULL=个人通知',
group_id INT DEFAULT NULL COMMENT '群组通知关联',
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 保存的筛选方案表
const hasSavedFilters = await tableExists(conn, 'saved_filters')
if (!hasSavedFilters) {
console.log('[DB] Migrating: creating saved_filters table')
await conn.query(`
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
filters JSON NOT NULL COMMENT '筛选条件',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
}
export async function initDatabase() {

View File

@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -72,3 +73,25 @@ CREATE TABLE IF NOT EXISTS group_members (
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告非NULL=个人通知',
group_id INT DEFAULT NULL COMMENT '群组通知关联',
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
filters JSON NOT NULL COMMENT '筛选条件:{type, categories[], startDate, endDate, minAmount, maxAmount, keyword}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -15,6 +15,9 @@ import categoryRoutes from './routes/category'
import backupRoutes from './routes/backup'
import userRoutes from './routes/user'
import groupRoutes from './routes/group'
import notificationRoutes from './routes/notification'
import filterRoutes from './routes/filter'
import adminRoutes from './routes/admin'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -107,6 +110,9 @@ app.use('/api/categories', apiLimiter, categoryRoutes)
app.use('/api/backup', apiLimiter, backupRoutes)
app.use('/api/user', apiLimiter, userRoutes)
app.use('/api/groups', apiLimiter, groupRoutes)
app.use('/api/notifications', apiLimiter, notificationRoutes)
app.use('/api/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes)
app.get('/api/health', async (_req, res) => {
try {

158
server/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,158 @@
import { Router, Response, NextFunction } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
/** 管理员权限检查中间件 */
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
}
next()
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
/** 数据看板 */
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
try {
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
const [txThisMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
)
const [txLastMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
FROM transactions
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
)
const [activeToday] = await pool.query(
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date = CURDATE()`
)
const [activeThisMonth] = await pool.query(
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
)
const thisMonth = (txThisMonth as any[])[0]
const lastMonth = (txLastMonth as any[])[0]
let growth = 0
if (lastMonth.total > 0) {
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
} else if (thisMonth.total > 0) {
growth = 100
}
res.json({
code: 0,
data: {
totalUsers: (userCount as any[])[0].count,
monthlyTxCount: thisMonth.count,
monthlyTxAmount: thisMonth.total,
dailyActive: (activeToday as any[])[0].count,
monthlyActive: (activeThisMonth as any[])[0].count,
growthPercent: growth,
lastMonthTxCount: lastMonth.count,
lastMonthTxAmount: lastMonth.total
}
})
} catch (err) {
console.error('[Admin] dashboard error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 用户列表 */
router.get('/users', async (req: AuthRequest, res: Response) => {
try {
const { page = '1', pageSize = '20', keyword } = req.query
const pNum = Math.max(1, parseInt(page as string) || 1)
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
const offset = (pNum - 1) * pSize
let where = ''
const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) {
where = 'WHERE u.nickname LIKE ?'
params.push(`%${keyword.trim()}%`)
}
const [rows] = await pool.query(
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count,
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
FROM users u
${where}
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
)
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM users u ${where}`,
params
)
res.json({
code: 0,
data: {
list: rows,
total: (countResult as any)[0].total,
page: pNum,
pageSize: pSize
}
})
} catch (err) {
console.error('[Admin] users error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 更新用户状态(禁用/启用) */
router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
try {
const { role } = req.body
if (!role || !['user', 'admin'].includes(role)) {
return res.status(400).json({ code: 40001, message: '角色无效' })
}
// 不能修改自己的角色
if (Number(req.params.id) === req.userId) {
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
}
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Admin] update user status error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 删除用户 */
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
try {
// 不能删除自己
if (Number(req.params.id) === req.userId) {
return res.status(400).json({ code: 40001, message: '不能删除自己' })
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Admin] delete user error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -23,10 +23,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
[demoOpenid, 'demo', 'demo']
)
const userId = (result as any).insertId
// 首个用户自动设为管理员
await autoSetAdmin(userId)
const token = signToken(userId)
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
} catch (err: any) {
console.error('[Auth] Demo login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -59,15 +63,27 @@ router.post('/login', async (req: Request, res: Response) => {
)
const userId = (result as any).insertId
// 首个用户自动设为管理员
await autoSetAdmin(userId)
// HMAC signed token with expiry
const token = signToken(userId)
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
} catch (err: any) {
console.error('[Auth] Login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 首个用户自动设为管理员 */
async function autoSetAdmin(userId: number) {
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin'])
if ((adminCount as any[])[0].count === 0) {
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
console.log(`[Auth] User ${userId} auto-promoted to admin`)
}
}
export default router

View File

@@ -0,0 +1,69 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
/** 获取保存的筛选方案 */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, name, filters, created_at FROM saved_filters WHERE user_id = ? ORDER BY created_at DESC',
[req.userId]
)
// 解析 JSON 字段
const list = (rows as any[]).map(row => ({
...row,
filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters
}))
res.json({ code: 0, data: list })
} catch (err) {
console.error('[Filter] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 保存筛选方案 */
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, filters } = req.body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '方案名称不能为空' })
}
if (name.length > 50) {
return res.status(400).json({ code: 40001, message: '方案名称不能超过50字' })
}
if (!filters || typeof filters !== 'object') {
return res.status(400).json({ code: 40001, message: '筛选条件无效' })
}
const [result] = await pool.query(
'INSERT INTO saved_filters (user_id, name, filters) VALUES (?, ?, ?)',
[req.userId, name.trim(), JSON.stringify(filters)]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Filter] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 删除筛选方案 */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'DELETE FROM saved_filters WHERE id = ? AND user_id = ?',
[req.params.id, req.userId]
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '方案不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Filter] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,129 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
/** 获取通知列表 */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { type, page = '1', pageSize = '20' } = req.query
const pNum = Math.max(1, parseInt(page as string) || 1)
const pSize = Math.min(50, Math.max(1, parseInt(pageSize as string) || 20))
const offset = (pNum - 1) * pSize
let where = 'WHERE (n.user_id = ? OR (n.type = \'system\' AND n.user_id IS NULL) OR (n.type = \'group\' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))'
const params: any[] = [req.userId, req.userId]
if (type && ['system', 'group', 'personal'].includes(type as string)) {
where += ' AND n.type = ?'
params.push(type)
}
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id
FROM notifications n
${where}
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
)
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM notifications n ${where}`,
params
)
res.json({
code: 0,
data: {
list: rows,
total: (countResult as any)[0].total,
page: pNum,
pageSize: pSize
}
})
} catch (err) {
console.error('[Notification] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 获取未读数量 */
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT COUNT(*) as count FROM notifications
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))) AND is_read = 0`,
[req.userId, req.userId]
)
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
} catch (err) {
console.error('[Notification] unread-count error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 标记单条已读 */
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id = ? OR (type = \'system\' AND user_id IS NULL) OR (type = \'group\' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))',
[req.params.id, req.userId, req.userId]
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '通知不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] mark-read error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 全部标记已读 */
router.put('/read-all', async (req: AuthRequest, res: Response) => {
try {
await pool.query(
`UPDATE notifications SET is_read = 1
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL)) AND is_read = 0`,
[req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] read-all error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 发布系统公告(管理员) */
router.post('/', async (req: AuthRequest, res: Response) => {
try {
// 验证管理员权限
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (userRows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '仅管理员可发布公告' })
}
const { title, content } = req.body
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '标题不能为空' })
}
if (title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
await pool.query(
'INSERT INTO notifications (type, title, content) VALUES (?, ?, ?)',
['system', title.trim(), content || '']
)
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -22,8 +22,8 @@ async function buildWhereClause(
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId)
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
params.push(groupId, groupId)
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}

View File

@@ -45,7 +45,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, minAmount, maxAmount, keyword } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
@@ -62,8 +62,8 @@ router.get('/', async (req: AuthRequest, res: Response) => {
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id)
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id, group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
@@ -79,6 +79,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
if (endDate && DATE_REGEX.test(endDate as string)) {
where += ' AND t.date <= ?'; params.push(endDate)
}
if (category_id) {
where += ' AND t.category_id = ?'; params.push(category_id)
}
if (minAmount) {
const min = parseInt(minAmount as string)
if (!isNaN(min) && min > 0) {
where += ' AND t.amount >= ?'; params.push(min)
}
}
if (maxAmount) {
const max = parseInt(maxAmount as string)
if (!isNaN(max) && max > 0) {
where += ' AND t.amount <= ?'; params.push(max)
}
}
if (keyword && typeof keyword === 'string' && keyword.trim()) {
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
}
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
@@ -100,13 +118,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
params
)
// 筛选后的合计金额
const [sumResult] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as totalIncome
FROM transactions t ${where}`,
params
)
res.json({
code: 0,
data: {
list: rows,
total: (countResult as any)[0].total,
page: pNum,
pageSize: pSize
pageSize: pSize,
filteredExpense: (sumResult as any)[0].totalExpense,
filteredIncome: (sumResult as any)[0].totalIncome
}
})
} catch (err) {

View File

@@ -48,7 +48,7 @@ const upload = multer({
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
[req.userId]
)
const user = (rows as any[])[0]