From 303671102bc00840ffb1733e81f394ab094e65dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Mon, 8 Sep 2025 09:20:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(controllers):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=8E=A7=E5=88=B6=E5=99=A8=E4=B8=BA=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E5=8D=95=E4=B8=80=E8=81=8C=E8=B4=A3=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将原PageController拆分为AuthPageController、BasePageController、ProfileController、UploadController,职责更加单一明确 - AuthPageController负责认证相关功能:登录、注册、验证码、登出等 - BasePageController负责基础页面功能:首页、静态页面和联系表单 - ProfileController负责用户资料管理、密码修改和头像上传 - UploadController负责通用文件上传功能 - 移除原PageController相关代码,优化代码结构和维护性 - 保留各控制器对应的路由创建方法,保持接口兼容性 --- database/development.sqlite3-shm | Bin 32768 -> 32768 bytes database/development.sqlite3-wal | Bin 696312 -> 828152 bytes src/controllers/Page/AuthPageController.js | 136 ++++++++ src/controllers/Page/BasePageController.js | 102 ++++++ src/controllers/Page/HtmxController.js | 63 ---- src/controllers/Page/PageController.js | 481 --------------------------- src/controllers/Page/ProfileController.js | 228 +++++++++++++ src/controllers/Page/UploadController.js | 200 +++++++++++ src/controllers/Page/_Demo/HtmxController.js | 63 ++++ src/utils/ForRegister.js | 6 +- 10 files changed, 733 insertions(+), 546 deletions(-) create mode 100644 src/controllers/Page/AuthPageController.js create mode 100644 src/controllers/Page/BasePageController.js delete mode 100644 src/controllers/Page/HtmxController.js delete mode 100644 src/controllers/Page/PageController.js create mode 100644 src/controllers/Page/ProfileController.js create mode 100644 src/controllers/Page/UploadController.js create mode 100644 src/controllers/Page/_Demo/HtmxController.js diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 3b94e9ba73ee083da6936dea1f86cb0a5393c796..c77930b3978a4e3719eda76d6e200a28b5a59ea5 100644 GIT binary patch delta 391 zcmZo@U}|V!s+V}A%K!t63=9G%fgB+qw%lMT{&R^+^DK>zc?ZKmFh@|7L773DA%r28p`Bp@!%l`n5MeO}6$TxKP=+{$4u**g pyBH2bv_8n^W8TehWb?;-X=dg<3`c>YXPGA7Ddk{124qTe0RTohf|CFM delta 317 zcmZo@U}|V!s+V}A%K!t63=9G*fgB+qez)9EE8wS_%l#kH^WVHbpnlBf-d>)Z+sjE+ z4>KEN?tdfz6=z~txv_CI)8;MAUiOm@Fn3J;=Onaw6Z0FU&3{~_n1QTEOp||j^8gv2 zm?nSovj8%VF-`swXaHoKW8&pwkY Nm?qyTj^PW4!sI1HqT;2!g|?JpDKFj9ieGn;;Y01e*{OYO*tNOqhw=Vn5R8#$vG$dN|z~5jEa_aBiL?cV?1* zT3i{mK?ey0U~Rj!xcH9)G9K;260oSJRrZeonfr#l!0B?OcgyD;Q6u)2B^DTJ-C@@a z$kW{%cOiobGYeMSEjPONo~*n(k4h5S9Rm=Dyt0nF&P75Xqt|$N9|I5b-B}=u{{`Ru z_(m@_pS*sFV{b6}asW#xF`}M6P;fBqB_!lZy;f(LV~A2*Q;?_L% zQmZseG}=sM*~Xl*i16T$^ff^tVYo~mFN<4P@Ft;7RRxEa7KJMkbh6?gttLkwrqRj? z9Zthaf+NIY^Cb0;TI-(1m}%XOgRuK_+dYD3+vLeomHjzQ`Y#VfJm@EwG zqqU>QxDG$K)fw}tqku3n@`Aw`T3+B=aE6vfGstv-CD8K<;JgM0LQ_+2=L|1|o{JNu zK{@mom*+n6!NZ}x&JEOXH|G$K>JegUHs~i}uaVN6x-o@QRf9+^Acq1lrSmh|Za(k! z-Obc@5+`eWHsnDrxQRq#S?}}_{XPDf)Nm(sh(}wmwICwTX(hE-L|*TOe5ZF>?oO^e zfy|%YDMLtReh)=?O}m{@>_*-{fz(b972`_oZ$*5jcZzo*ds~r!&}sLh(~i+&{HXK& zZhu|uulA>sJg~jM;Nbv^phBgd$jZ(U@nBEO_dIQm)}1+E^NO4(lb+mNZ#z0a z8)EeOpE8ucbkvrMMRbIlOHn#P>Rc(>3@}#Tj@=L^v&$l3FODMu&w_BBUMb5X3IzbR zCR3)(S15Bl$QAWyG}e$+m(esQ%#o_DN4LQcpcXrzneg=2rHu=Ky5ayQKoFmb*~}hd z|MS1^Y@xZr6$&1;shlnN7XVq|v^Hij*#q?CNRM$@Y+mL*<9y-OKB}MxBLO5JYiij( zC~$`(ArdcVyFh%>(1y+zGSV>1!}`2i`dQ$?_1i-|sdH`U9_EkxFQTpv6LkTm8u(^| z&x8j9$Dhn4|0}1F8#yDR)PYf2CJ6!oJQo_+-Q0heyjy+f-mY@M82T&r^qp=Pcz0`m zCCO(DgU8zk+Z+4ZcMi5!GLTG&c(7Qok_{l_o$im)z;z6j5UMmt|Bz7yExMc|VAZ5ZVc_s!M)z z&!$O+$4VdjyV4(8$BlLys*2C|Zt`2qf}L7(vq^%RFR=YVs%k$@`~8p)gemh>eMBv8H$ALG=gb#rkqPi zlsMdm371*k<_^q8r)YSEt>Ton(9G8GZ1?z|wu-hoMT4aqdnK)+txnPKN?JwN$mU4R gqJxi_MF7M2#)7Pc1l7LFFqEnGk50|2^|3wHnj 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/HtmxController.js b/src/controllers/Page/HtmxController.js deleted file mode 100644 index 9908a22..0000000 --- a/src/controllers/Page/HtmxController.js +++ /dev/null @@ -1,63 +0,0 @@ -import Router from "utils/router.js" - -class HtmxController { - async index(ctx) { - return await ctx.render("index", { name: "bluescurry" }) - } - - page(name, data) { - return async ctx => { - return await ctx.render(name, data) - } - } - - static createRoutes() { - const controller = new HtmxController() - const router = new Router({ auth: "try" }) - router.get("/htmx/timeline", async ctx => { - return await ctx.render("htmx/timeline", { - timeLine: [ - { - icon: "第一份工作", - title: "???", - desc: `做游戏的。`, - }, - { - icon: "大学毕业", - title: "2014年09月", - desc: `我从江西师范大学毕业, - 获得了软件工程(虚拟现实与技术)专业的学士学位。`, - }, - { - icon: "高中", - title: "???", - desc: `宜春中学`, - }, - { - icon: "初中", - title: "???", - desc: `宜春实验中学`, - }, - { - icon: "小学(4-6年级)", - title: "???", - desc: `宜春二小`, - }, - { - icon: "小学(1-3年级)", - title: "???", - desc: `丰城市泉港镇小学`, - }, - { - icon: "出生", - title: "1996年06月", - desc: `我出生于江西省丰城市泉港镇`, - }, - ], - }) - }) - return router - } -} - -export default HtmxController 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/_Demo/HtmxController.js b/src/controllers/Page/_Demo/HtmxController.js new file mode 100644 index 0000000..9908a22 --- /dev/null +++ b/src/controllers/Page/_Demo/HtmxController.js @@ -0,0 +1,63 @@ +import Router from "utils/router.js" + +class HtmxController { + async index(ctx) { + return await ctx.render("index", { name: "bluescurry" }) + } + + page(name, data) { + return async ctx => { + return await ctx.render(name, data) + } + } + + static createRoutes() { + const controller = new HtmxController() + const router = new Router({ auth: "try" }) + router.get("/htmx/timeline", async ctx => { + return await ctx.render("htmx/timeline", { + timeLine: [ + { + icon: "第一份工作", + title: "???", + desc: `做游戏的。`, + }, + { + icon: "大学毕业", + title: "2014年09月", + desc: `我从江西师范大学毕业, + 获得了软件工程(虚拟现实与技术)专业的学士学位。`, + }, + { + icon: "高中", + title: "???", + desc: `宜春中学`, + }, + { + icon: "初中", + title: "???", + desc: `宜春实验中学`, + }, + { + icon: "小学(4-6年级)", + title: "???", + desc: `宜春二小`, + }, + { + icon: "小学(1-3年级)", + title: "???", + desc: `丰城市泉港镇小学`, + }, + { + icon: "出生", + title: "1996年06月", + desc: `我出生于江西省丰城市泉港镇`, + }, + ], + }) + }) + return router + } +} + +export default HtmxController 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)