diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 3b94e9b..c77930b 100644 Binary files a/database/development.sqlite3-shm and b/database/development.sqlite3-shm differ diff --git a/database/development.sqlite3-wal b/database/development.sqlite3-wal index bc8a314..a90505f 100644 Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ diff --git a/src/controllers/Page/AuthPageController.js b/src/controllers/Page/AuthPageController.js new file mode 100644 index 0000000..1fd68b0 --- /dev/null +++ b/src/controllers/Page/AuthPageController.js @@ -0,0 +1,136 @@ +import Router from "utils/router.js" +import UserService from "services/userService.js" +import svgCaptcha from "svg-captcha" +import CommonError from "@/utils/error/CommonError" +import { logger } from "@/logger.js" + +/** + * 认证相关页面控制器 + * 负责处理登录、注册、验证码、登出等认证相关功能 + */ +class AuthPageController { + constructor() { + this.userService = new UserService() + } + + // 未授权报错页 + async indexNoAuth(ctx) { + return await ctx.render("page/auth/no-auth", {}) + } + + // 登录页 + async loginGet(ctx) { + if (ctx.session.user) { + ctx.status = 200 + ctx.redirect("/?msg=用户已登录") + return + } + return await ctx.render("page/login/index", { site_title: "登录" }) + } + + // 处理登录请求 + async loginPost(ctx) { + const { username, email, password } = ctx.request.body + const result = await this.userService.login({ username, email, password }) + ctx.session.user = result.user + ctx.body = { success: true, message: "登录成功" } + } + + // 获取验证码 + async captchaGet(ctx) { + var captcha = svgCaptcha.create({ + size: 4, // 个数 + width: 100, // 宽 + height: 30, // 高 + fontSize: 38, // 字体大小 + color: true, // 字体颜色是否多变 + noise: 4, // 干扰线几条 + }) + // 记录验证码信息(文本+过期时间) + // 这里设置5分钟后过期 + const expireTime = Date.now() + 5 * 60 * 1000 + ctx.session.captcha = { + text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证 + expireTime: expireTime, + } + ctx.type = "image/svg+xml" + ctx.body = captcha.data + } + + // 注册页 + async registerGet(ctx) { + if (ctx.session.user) { + return ctx.redirect("/?msg=用户已登录") + } + return await ctx.render("page/register/index", { site_title: "注册" }) + } + + // 处理注册请求 + async registerPost(ctx) { + const { username, password, code } = ctx.request.body + + // 检查Session中是否存在验证码 + if (!ctx.session.captcha) { + throw new CommonError("验证码不存在,请重新获取") + } + + const { text, expireTime } = ctx.session.captcha + + // 检查是否过期 + if (Date.now() > expireTime) { + // 过期后清除Session中的验证码 + delete ctx.session.captcha + throw new CommonError("验证码已过期,请重新获取") + } + + if (!code) { + throw new CommonError("请输入验证码") + } + + if (code.toLowerCase() !== text) { + throw new CommonError("验证码错误") + } + + delete ctx.session.captcha + + await this.userService.register({ username, name: username, password, role: "user" }) + return ctx.redirect("/login") + } + + // 退出登录 + async logout(ctx) { + ctx.status = 200 + delete ctx.session.user + ctx.set("hx-redirect", "/") + } + + /** + * 创建认证相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new AuthPageController() + const router = new Router({ auth: "try" }) + + // 未授权报错页 + router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) + + // 登录相关 + router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) + router.post("/login", controller.loginPost.bind(controller), { auth: false }) + + // 注册相关 + router.get("/register", controller.registerGet.bind(controller), { auth: "try" }) + router.post("/register", controller.registerPost.bind(controller), { auth: false }) + + // 验证码 + router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) + + // 登出 + router.post("/logout", controller.logout.bind(controller), { auth: true }) + + return router + } +} + +export default AuthPageController \ No newline at end of file diff --git a/src/controllers/Page/BasePageController.js b/src/controllers/Page/BasePageController.js new file mode 100644 index 0000000..411a431 --- /dev/null +++ b/src/controllers/Page/BasePageController.js @@ -0,0 +1,102 @@ +import Router from "utils/router.js" +import ArticleService from "services/ArticleService.js" +import { logger } from "@/logger.js" + +/** + * 基础页面控制器 + * 负责处理首页、静态页面、联系表单等基础功能 + */ +class BasePageController { + constructor() { + this.articleService = new ArticleService() + } + + // 首页 + async indexGet(ctx) { + const blogs = await this.articleService.getPublishedArticles() + return await ctx.render( + "page/index/index", + { + apiList: [ + { + name: "随机图片", + desc: "随机图片,点击查看。
右键可复制链接", + url: "https://pic.xieyaxin.top/random.php", + }, + ], + blogs: blogs.slice(0, 4), + }, + { includeSite: true, includeUser: true } + ) + } + + // 处理联系表单提交 + async contactPost(ctx) { + const { name, email, subject, message } = ctx.request.body + + // 简单的表单验证 + if (!name || !email || !subject || !message) { + ctx.status = 400 + ctx.body = { success: false, message: "请填写所有必填字段" } + return + } + + // 这里可以添加邮件发送逻辑或数据库存储逻辑 + // 目前只是简单的成功响应 + logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`) + + ctx.body = { + success: true, + message: "感谢您的留言,我们会尽快回复您!", + } + } + + /** + * 通用页面渲染方法 + * @param {string} name - 模板名称 + * @param {Object} data - 页面数据 + * @returns {Function} 页面渲染函数 + */ + pageGet(name, data) { + return async ctx => { + return await ctx.render( + name, + { + ...(data || {}), + }, + { includeSite: true, includeUser: true } + ) + } + } + + /** + * 创建基础页面相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new BasePageController() + const router = new Router({ auth: "try" }) + + // 首页 + router.get("/", controller.indexGet.bind(controller), { auth: false }) + + // 静态页面 + router.get("/about", controller.pageGet("page/about/index"), { auth: false }) + router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false }) + router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false }) + router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false }) + router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false }) + router.get("/help", controller.pageGet("page/extra/help"), { auth: false }) + router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false }) + + // 需要登录的页面 + router.get("/notice", controller.pageGet("page/notice/index"), { auth: true }) + + // 联系表单处理 + router.post("/contact", controller.contactPost.bind(controller), { auth: false }) + + return router + } +} + +export default BasePageController \ No newline at end of file diff --git a/src/controllers/Page/PageController.js b/src/controllers/Page/PageController.js deleted file mode 100644 index bfffa90..0000000 --- a/src/controllers/Page/PageController.js +++ /dev/null @@ -1,481 +0,0 @@ -import Router from "utils/router.js" -import UserService from "services/userService.js" -import SiteConfigService from "services/SiteConfigService.js" -import ArticleService from "services/ArticleService.js" -import svgCaptcha from "svg-captcha" -import formidable from "formidable" -import fs from "fs/promises" -import path from "path" -import { fileURLToPath } from "url" -import CommonError from "@/utils/error/CommonError" -import { logger } from "@/logger.js" -import { R } from "@/utils/helper" -import imageThumbnail from "image-thumbnail" - -class PageController { - constructor() { - this.userService = new UserService() - this.siteConfigService = new SiteConfigService() - this.siteConfigService = new SiteConfigService() - this.articleService = new ArticleService() - } - - // 首页 - async indexGet(ctx) { - const blogs = await this.articleService.getPublishedArticles() - return await ctx.render( - "page/index/index", - { - apiList: [ - { - name: "随机图片", - desc: "随机图片,点击查看。
右键可复制链接", - url: "https://pic.xieyaxin.top/random.php", - }, - ], - blogs: blogs.slice(0, 4), - }, - { includeSite: true, includeUser: true } - ) - } - - // 未授权报错页 - async indexNoAuth(ctx) { - return await ctx.render("page/auth/no-auth", {}) - } - - // 登录页 - async loginGet(ctx) { - if (ctx.session.user) { - ctx.status = 200 - ctx.redirect("/?msg=用户已登录") - return - } - return await ctx.render("page/login/index", { site_title: "登录" }) - } - - // 处理登录请求 - async loginPost(ctx) { - const { username, email, password } = ctx.request.body - const result = await this.userService.login({ username, email, password }) - ctx.session.user = result.user - ctx.body = { success: true, message: "登录成功" } - } - - // 获取验证码 - async captchaGet(ctx) { - var captcha = svgCaptcha.create({ - size: 4, // 个数 - width: 100, // 宽 - height: 30, // 高 - fontSize: 38, // 字体大小 - color: true, // 字体颜色是否多变 - noise: 4, // 干扰线几条 - }) - // 记录验证码信息(文本+过期时间) - // 这里设置5分钟后过期 - const expireTime = Date.now() + 5 * 60 * 1000 - ctx.session.captcha = { - text: captcha.text.toLowerCase(), // 转小写,忽略大小写验证 - expireTime: expireTime, - } - ctx.type = "image/svg+xml" - ctx.body = captcha.data - } - - // 注册页 - async registerGet(ctx) { - if (ctx.session.user) { - return ctx.redirect("/?msg=用户已登录") - } - // TODO 多个 - return await ctx.render("page/register/index", { site_title: "注册" }) - } - - // 处理注册请求 - async registerPost(ctx) { - const { username, password, code } = ctx.request.body - - // 检查Session中是否存在验证码 - if (!ctx.session.captcha) { - throw new CommonError("验证码不存在,请重新获取") - } - - const { text, expireTime } = ctx.session.captcha - - // 检查是否过期 - if (Date.now() > expireTime) { - // 过期后清除Session中的验证码 - delete ctx.session.captcha - throw new CommonError("验证码已过期,请重新获取") - } - - if (!code) { - throw new CommonError("请输入验证码") - } - - if (code.toLowerCase() !== text) { - throw new CommonError("验证码错误") - } - - delete ctx.session.captcha - - await this.userService.register({ username, name: username, password, role: "user" }) - return ctx.redirect("/login") - } - - // 退出登录 - async logout(ctx) { - ctx.status = 200 - delete ctx.session.user - ctx.set("hx-redirect", "/") - } - - // 获取用户资料 - 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 || "修改密码失败" } - } - } - - // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组) - async upload(ctx) { - try { - const __dirname = path.dirname(fileURLToPath(import.meta.url)) - const publicDir = path.resolve(__dirname, "../../../public") - const uploadsDir = path.resolve(publicDir, "uploads/files") - // 确保目录存在 - await fs.mkdir(uploadsDir, { recursive: true }) - - // 只需配置一个类型-扩展名映射数组 - const defaultTypeList = [ - { mime: "image/jpeg", ext: ".jpg" }, - { mime: "image/png", ext: ".png" }, - { mime: "image/webp", ext: ".webp" }, - { mime: "image/gif", ext: ".gif" }, - { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx - { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls - { mime: "application/msword", ext: ".doc" }, // .doc - { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx - ] - let typeList = defaultTypeList - - // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型) - if (ctx.query.allowedTypes) { - const allowed = ctx.query.allowedTypes - .split(",") - .map(t => t.trim()) - .filter(Boolean) - typeList = defaultTypeList.filter(item => allowed.includes(item.mime)) - } - - const allowedTypes = typeList.map(item => item.mime) - const fallbackExt = ".bin" - - const form = formidable({ - multiples: true, // 支持多文件 - maxFileSize: 10 * 1024 * 1024, // 10MB - filter: ({ mimetype }) => { - return !!mimetype && allowedTypes.includes(mimetype) - }, - uploadDir: uploadsDir, - keepExtensions: true, - }) - - const { files } = await new Promise((resolve, reject) => { - form.parse(ctx.req, (err, fields, files) => { - if (err) return reject(err) - resolve({ fields, files }) - }) - }) - - let fileList = files.file - if (!fileList) { - return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file") - } - // 统一为数组 - if (!Array.isArray(fileList)) { - fileList = [fileList] - } - - // 处理所有文件 - const urls = [] - for (const picked of fileList) { - if (!picked) continue - const oldPath = picked.filepath || picked.path - // 优先用mimetype判断扩展名 - let ext = (typeList.find(item => item.mime === picked.mimetype) || {}).ext - if (!ext) { - // 回退到原始文件名的扩展名 - ext = path.extname(picked.originalFilename || picked.newFilename || "") || fallbackExt - } - // 文件名 - const filename = `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` - const destPath = path.join(uploadsDir, filename) - // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名 - if (oldPath && oldPath !== destPath) { - await fs.rename(oldPath, destPath) - } - // 注意:此处url路径与public下的uploads/files对应 - const url = `/uploads/files/${filename}` - urls.push(url) - } - - ctx.body = { - success: true, - message: "上传成功", - urls, - } - } 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 || "上传头像失败" } - } - } - - // 处理联系表单提交 - async contactPost(ctx) { - const { name, email, subject, message } = ctx.request.body - - // 简单的表单验证 - if (!name || !email || !subject || !message) { - ctx.status = 400 - ctx.body = { success: false, message: "请填写所有必填字段" } - return - } - - // 这里可以添加邮件发送逻辑或数据库存储逻辑 - // 目前只是简单的成功响应 - logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`) - - ctx.body = { - success: true, - message: "感谢您的留言,我们会尽快回复您!", - } - } - - // 渲染页面 - pageGet(name, data) { - return async ctx => { - return await ctx.render( - name, - { - ...(data || {}), - }, - { includeSite: true, includeUser: true } - ) - } - } - - static createRoutes() { - const controller = new PageController() - const router = new Router({ auth: "try" }) - // 首页 - router.get("/", controller.indexGet.bind(controller), { auth: false }) - // 未授权报错页 - router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) - - // router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false }) - // router.get("/articles", controller.pageGet("page/articles/index"), { auth: false }) - - router.get("/about", controller.pageGet("page/about/index"), { auth: false }) - router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false }) - router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false }) - router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false }) - router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false }) - 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 }) - router.get("/notice", controller.pageGet("page/notice/index"), { auth: true }) - router.get("/help", controller.pageGet("page/extra/help"), { auth: false }) - router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false }) - router.post("/contact", controller.contactPost.bind(controller), { auth: false }) - router.get("/login", controller.loginGet.bind(controller), { auth: "try" }) - router.post("/login", controller.loginPost.bind(controller), { auth: false }) - router.get("/captcha", controller.captchaGet.bind(controller), { auth: false }) - router.get("/register", controller.registerGet.bind(controller), { auth: "try" }) - router.post("/register", controller.registerPost.bind(controller), { auth: false }) - router.post("/logout", controller.logout.bind(controller), { auth: true }) - return router - } -} - -export default PageController diff --git a/src/controllers/Page/ProfileController.js b/src/controllers/Page/ProfileController.js new file mode 100644 index 0000000..3a3678c --- /dev/null +++ b/src/controllers/Page/ProfileController.js @@ -0,0 +1,228 @@ +import Router from "utils/router.js" +import UserService from "services/userService.js" +import formidable from "formidable" +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import CommonError from "@/utils/error/CommonError" +import { logger } from "@/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 \ No newline at end of file diff --git a/src/controllers/Page/UploadController.js b/src/controllers/Page/UploadController.js new file mode 100644 index 0000000..e172b7f --- /dev/null +++ b/src/controllers/Page/UploadController.js @@ -0,0 +1,200 @@ +import Router from "utils/router.js" +import formidable from "formidable" +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" +import { logger } from "@/logger.js" +import { R } from "@/utils/helper" + +/** + * 文件上传控制器 + * 负责处理通用文件上传功能 + */ +class UploadController { + constructor() { + // 初始化上传配置 + this.initConfig() + } + + /** + * 初始化上传配置 + */ + initConfig() { + // 默认支持的文件类型配置 + this.defaultTypeList = [ + { mime: "image/jpeg", ext: ".jpg" }, + { mime: "image/png", ext: ".png" }, + { mime: "image/webp", ext: ".webp" }, + { mime: "image/gif", ext: ".gif" }, + { mime: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ext: ".xlsx" }, // .xlsx + { mime: "application/vnd.ms-excel", ext: ".xls" }, // .xls + { mime: "application/msword", ext: ".doc" }, // .doc + { mime: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ext: ".docx" }, // .docx + ] + + this.fallbackExt = ".bin" + this.maxFileSize = 10 * 1024 * 1024 // 10MB + } + + /** + * 获取允许的文件类型 + * @param {Object} ctx - Koa上下文 + * @returns {Array} 允许的文件类型列表 + */ + getAllowedTypes(ctx) { + let typeList = this.defaultTypeList + + // 支持通过ctx.query.allowedTypes自定义类型(逗号分隔,自动过滤无效类型) + if (ctx.query.allowedTypes) { + const allowed = ctx.query.allowedTypes + .split(",") + .map(t => t.trim()) + .filter(Boolean) + typeList = this.defaultTypeList.filter(item => allowed.includes(item.mime)) + } + + return typeList + } + + /** + * 获取上传目录路径 + * @returns {string} 上传目录路径 + */ + getUploadDir() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)) + const publicDir = path.resolve(__dirname, "../../../public") + return path.resolve(publicDir, "uploads/files") + } + + /** + * 确保上传目录存在 + * @param {string} dir - 目录路径 + */ + async ensureUploadDir(dir) { + await fs.mkdir(dir, { recursive: true }) + } + + /** + * 生成安全的文件名 + * @param {Object} ctx - Koa上下文 + * @param {string} ext - 文件扩展名 + * @returns {string} 生成的文件名 + */ + generateFileName(ctx, ext) { + return `${ctx.session.user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}` + } + + /** + * 获取文件扩展名 + * @param {Object} file - 文件对象 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件扩展名 + */ + getFileExtension(file, typeList) { + // 优先用mimetype判断扩展名 + let ext = (typeList.find(item => item.mime === file.mimetype) || {}).ext + if (!ext) { + // 回退到原始文件名的扩展名 + ext = path.extname(file.originalFilename || file.newFilename || "") || this.fallbackExt + } + return ext + } + + /** + * 处理单个文件上传 + * @param {Object} file - 文件对象 + * @param {Object} ctx - Koa上下文 + * @param {string} uploadsDir - 上传目录 + * @param {Array} typeList - 类型列表 + * @returns {string} 文件URL + */ + async processFile(file, ctx, uploadsDir, typeList) { + if (!file) return null + + const oldPath = file.filepath || file.path + const ext = this.getFileExtension(file, typeList) + const filename = this.generateFileName(ctx, ext) + const destPath = path.join(uploadsDir, filename) + + // 移动文件到目标位置 + if (oldPath && oldPath !== destPath) { + await fs.rename(oldPath, destPath) + } + + // 返回相对于public的URL路径 + return `/uploads/files/${filename}` + } + + // 支持多文件上传,支持图片、Excel、Word文档类型,可通过配置指定允许的类型和扩展名(只需配置一个数组) + async upload(ctx) { + try { + const uploadsDir = this.getUploadDir() + await this.ensureUploadDir(uploadsDir) + + const typeList = this.getAllowedTypes(ctx) + const allowedTypes = typeList.map(item => item.mime) + + const form = formidable({ + multiples: true, // 支持多文件 + maxFileSize: this.maxFileSize, + filter: ({ mimetype }) => { + return !!mimetype && allowedTypes.includes(mimetype) + }, + uploadDir: uploadsDir, + keepExtensions: true, + }) + + const { files } = await new Promise((resolve, reject) => { + form.parse(ctx.req, (err, fields, files) => { + if (err) return reject(err) + resolve({ fields, files }) + }) + }) + + let fileList = files.file + if (!fileList) { + return R.ResponseJSON(R.ERROR, null, "未选择文件或字段名应为 file") + } + + // 统一为数组 + if (!Array.isArray(fileList)) { + fileList = [fileList] + } + + // 处理所有文件 + const urls = [] + for (const file of fileList) { + const url = await this.processFile(file, ctx, uploadsDir, typeList) + if (url) { + urls.push(url) + } + } + + ctx.body = { + success: true, + message: "上传成功", + urls, + } + } catch (error) { + logger.error(`上传失败: ${error.message}`) + ctx.status = 500 + ctx.body = { success: false, message: error.message || "上传失败" } + } + } + + /** + * 创建文件上传相关路由 + * @returns {Router} 路由实例 + */ + static createRoutes() { + const controller = new UploadController() + const router = new Router({ auth: "try" }) + + // 通用文件上传 + router.post("/upload", controller.upload.bind(controller), { auth: true }) + + return router + } +} + +export default UploadController \ No newline at end of file diff --git a/src/controllers/Page/HtmxController.js b/src/controllers/Page/_Demo/HtmxController.js similarity index 100% rename from src/controllers/Page/HtmxController.js rename to src/controllers/Page/_Demo/HtmxController.js diff --git a/src/utils/ForRegister.js b/src/utils/ForRegister.js index f21bcf3..39b1b70 100644 --- a/src/utils/ForRegister.js +++ b/src/utils/ForRegister.js @@ -30,8 +30,10 @@ export function autoRegisterControllers(app, controllersDir) { const stat = fs.statSync(fullPath) if (stat.isDirectory()) { - scan(fullPath, routePrefix + "/" + file) - } else if (file.endsWith("Controller.js")) { + if (!file.startsWith("_")) { + scan(fullPath, routePrefix + "/" + file) + } + } else if (file.endsWith("Controller.js") && !file.startsWith("_")) { try { // 使用同步的import方式,确保ES模块兼容性 const controllerModule = require(fullPath)