You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

228 lines
8.1 KiB

import Router from "../../../shared/utils/router.js"
import UserService from "../../auth/services/userService.js"
import formidable from "formidable"
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import CommonError from "../../../shared/utils/error/CommonError.js"
import { logger } from "../../../app/bootstrap/logger.js"
import imageThumbnail from "image-thumbnail"
/**
* 用户资料控制器
* 负责处理用户资料管理、密码修改、头像上传等功能
*/
class ProfileController {
constructor() {
this.userService = new UserService()
}
// 获取用户资料
async profileGet(ctx) {
if (!ctx.session.user) {
return ctx.redirect("/login")
}
try {
const user = await this.userService.getUserById(ctx.session.user.id)
return await ctx.render(
"page/profile/index",
{
user,
site_title: "用户资料",
},
{ includeSite: true, includeUser: true }
)
} catch (error) {
logger.error(`获取用户资料失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: "获取用户资料失败" }
}
}
// 更新用户资料
async profileUpdate(ctx) {
if (!ctx.session.user) {
ctx.status = 401
ctx.body = { success: false, message: "未登录" }
return
}
try {
const { username, email, name, bio, avatar } = ctx.request.body
// 验证必填字段
if (!username) {
ctx.status = 400
ctx.body = { success: false, message: "用户名不能为空" }
return
}
const updateData = { username, email, name, bio, avatar }
// 移除空值
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined || updateData[key] === null || updateData[key] === "") {
delete updateData[key]
}
})
const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData)
// 更新session中的用户信息
ctx.session.user = { ...ctx.session.user, ...updatedUser }
ctx.body = {
success: true,
message: "资料更新成功",
user: updatedUser,
}
} catch (error) {
logger.error(`更新用户资料失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: error.message || "更新用户资料失败" }
}
}
// 修改密码
async changePassword(ctx) {
if (!ctx.session.user) {
ctx.status = 401
ctx.body = { success: false, message: "未登录" }
return
}
try {
const { oldPassword, newPassword, confirmPassword } = ctx.request.body
if (!oldPassword || !newPassword || !confirmPassword) {
ctx.status = 400
ctx.body = { success: false, message: "请填写所有密码字段" }
return
}
if (newPassword !== confirmPassword) {
ctx.status = 400
ctx.body = { success: false, message: "新密码与确认密码不匹配" }
return
}
if (newPassword.length < 6) {
ctx.status = 400
ctx.body = { success: false, message: "新密码长度不能少于6位" }
return
}
await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword)
ctx.body = {
success: true,
message: "密码修改成功",
}
} catch (error) {
logger.error(`修改密码失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: error.message || "修改密码失败" }
}
}
// 上传头像(multipart/form-data)
async uploadAvatar(ctx) {
try {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.resolve(__dirname, "../../../public")
const avatarsDir = path.resolve(publicDir, "uploads/avatars")
// 确保目录存在
await fs.mkdir(avatarsDir, { recursive: true })
const form = formidable({
multiples: false,
maxFileSize: 5 * 1024 * 1024, // 5MB
filter: ({ mimetype }) => {
return !!mimetype && /^(image\/jpeg|image\/png|image\/webp|image\/gif)$/.test(mimetype)
},
uploadDir: avatarsDir,
keepExtensions: true,
})
const { files } = await new Promise((resolve, reject) => {
form.parse(ctx.req, (err, fields, files) => {
if (err) return reject(err)
resolve({ fields, files })
})
})
const file = files.avatar || files.file || files.image
const picked = Array.isArray(file) ? file[0] : file
if (!picked) {
ctx.status = 400
ctx.body = { success: false, message: "未选择文件或字段名应为 avatar" }
return
}
// formidable v2 的文件对象
const oldPath = picked.filepath || picked.path
const result = { url: "", thumb: "" }
const ext = path.extname(picked.originalFilename || picked.newFilename || "") || path.extname(oldPath || "") || ".jpg"
const safeExt = [".jpg", ".jpeg", ".png", ".webp", ".gif"].includes(ext.toLowerCase()) ? ext : ".jpg"
const filename = `${ctx.session.user.id}-${Date.now()}/raw${safeExt}`
const destPath = path.join(avatarsDir, filename)
// 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名
if (oldPath && oldPath !== destPath) {
await fs.mkdir(path.parse(destPath).dir, { recursive: true })
await fs.rename(oldPath, destPath)
try {
const thumbnail = await imageThumbnail(destPath)
fs.writeFile(destPath.replace(/raw\./, "thumb."), thumbnail)
} catch (err) {
console.error(err)
}
}
const url = `/uploads/avatars/${filename}`
result.url = url
result.thumb = url.replace(/raw\./, "thumb.")
const updatedUser = await this.userService.updateUser(ctx.session.user.id, { avatar: url })
ctx.session.user = { ...ctx.session.user, ...updatedUser }
ctx.body = {
success: true,
message: "头像上传成功",
url,
thumb: result.thumb,
user: updatedUser,
}
} catch (error) {
logger.error(`上传头像失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: error.message || "上传头像失败" }
}
}
/**
* 创建用户资料相关路由
* @returns {Router} 路由实例
*/
static createRoutes() {
const controller = new ProfileController()
const router = new Router({ auth: "try" })
// 用户资料页面
router.get("/profile", controller.profileGet.bind(controller), { auth: true })
// 用户资料更新
router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true })
// 密码修改
router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true })
// 头像上传
router.post("/profile/upload-avatar", controller.uploadAvatar.bind(controller), { auth: true })
return router
}
}
export default ProfileController