- 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
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
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
|