Files
xiaocai/server/src/routes/user.ts
wangxiaogang 263a7c4616 feat(iter-v2): T01 complete - security + infrastructure
- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore
- S3: TOKEN_SECRET centralized to config/token.ts, all files import from it
- S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup
- S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token)
- S5: POST /auth/refresh endpoint with rotation and max 5 per user
- S5: Frontend request.ts auto-refresh on 40101, concurrent-safe
- S6: Soft delete (deleted_at column), admin soft-delete with confirmName
- S6: POST /admin/users/:id/restore endpoint
- S6: All user queries filter deleted_at IS NULL
- S7: DB connection requires env vars, no fallback credentials
- Infra: jest.config.js, vitest.config.ts, .env.test
- Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
2026-06-11 09:24:10 +08:00

152 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router, Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
// 头像上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars')
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 确保目录存在(可能被手动删除)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
cb(null, AVATAR_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 获取当前用户信息 */
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ? AND deleted_at IS NULL',
[req.userId]
)
const user = (rows as any[])[0]
if (!user) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0, data: user })
} catch (err) {
console.error('[User] GET /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 更新用户昵称/签名 */
router.put('/me', async (req: AuthRequest, res: Response) => {
try {
const { nickname, slogan } = req.body
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '昵称不能为空' })
}
if (nickname.length > 50) {
return res.status(400).json({ code: 40001, message: '昵称不能超过50个字符' })
}
}
if (slogan !== undefined) {
if (typeof slogan !== 'string') {
return res.status(400).json({ code: 40001, message: '签名格式错误' })
}
if (slogan.length > 100) {
return res.status(400).json({ code: 40001, message: '签名不能超过100个字符' })
}
}
const updates: string[] = []
const params: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
params.push(nickname.trim())
}
if (slogan !== undefined) {
updates.push('slogan = ?')
params.push(slogan.trim())
}
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}
params.push(req.userId)
await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
res.json({ code: 0 })
} catch (err) {
console.error('[User] PUT /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 上传头像 */
router.post('/avatar', (req: AuthRequest, res: Response) => {
upload.single('file')(req, res, async (err) => {
if (err) {
return res.status(400).json({ code: 40001, message: err.message })
}
if (!req.file) {
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
}
try {
// 先查询旧头像
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const oldUrl = (rows as any[])[0]?.avatar_url
// 先更新 DB再删旧文件保证 DB 和文件至少有一个正确)
await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [req.file.filename, req.userId])
if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
try {
fs.unlinkSync(oldPath)
} catch (e) {
console.warn('[User] 删除旧头像失败:', e)
}
}
}
res.json({ code: 0, data: { avatar_url: req.file.filename } })
} catch (err) {
console.error('[User] avatar upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
export default router