diff --git a/bun.lockb b/bun.lockb index 965abd3..649d0f4 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 264d105..4ff9c78 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 9f44bc8..0e8f5e5 100644 Binary files a/database/development.sqlite3-wal and b/database/development.sqlite3-wal differ diff --git a/package.json b/package.json index e1ddd8d..cbb4de0 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@koa/etag": "^5.0.1", "bcryptjs": "^3.0.2", "consolidate": "^1.0.4", + "formidable": "^3.5.4", "get-paths": "^0.0.7", "jsonwebtoken": "^9.0.0", "knex": "^3.1.0", diff --git a/public/uploads/avatars/.gitkeep b/public/uploads/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/uploads/files/.gitkeep b/public/uploads/files/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/test-profile.js b/scripts/test-profile.js deleted file mode 100644 index 5f30e81..0000000 --- a/scripts/test-profile.js +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env node - -/** - * 用户资料系统测试脚本 - * 用于验证系统功能是否正常 - */ - -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; - -console.log('🧪 开始测试用户资料系统...\n'); - -// 检查必要的文件是否存在 -const requiredFiles = [ - 'src/controllers/Page/PageController.js', - 'src/views/page/profile/index.pug', - 'public/js/profile.js', - 'src/db/migrations/20250901000000_add_profile_fields.mjs' -]; - -console.log('📁 检查必要文件...'); -let allFilesExist = true; - -requiredFiles.forEach(file => { - if (fs.existsSync(file)) { - console.log(`✅ ${file}`); - } else { - console.log(`❌ ${file} - 文件不存在`); - allFilesExist = false; - } -}); - -if (!allFilesExist) { - console.log('\n❌ 部分必要文件缺失,请检查文件创建'); - process.exit(1); -} - -console.log('\n✅ 所有必要文件都存在'); - -// 检查数据库迁移文件 -console.log('\n🗄️ 检查数据库迁移...'); -try { - const migrationContent = fs.readFileSync('src/db/migrations/20250901000000_add_profile_fields.mjs', 'utf8'); - if (migrationContent.includes('name') && migrationContent.includes('bio') && migrationContent.includes('avatar')) { - console.log('✅ 数据库迁移文件包含必要字段'); - } else { - console.log('❌ 数据库迁移文件缺少必要字段'); - } -} catch (error) { - console.log('❌ 无法读取数据库迁移文件'); -} - -// 检查路由配置 -console.log('\n🛣️ 检查路由配置...'); -try { - const controllerContent = fs.readFileSync('src/controllers/Page/PageController.js', 'utf8'); - - const hasProfileGet = controllerContent.includes('profileGet'); - const hasProfileUpdate = controllerContent.includes('profileUpdate'); - const hasChangePassword = controllerContent.includes('changePassword'); - const hasProfileRoutes = controllerContent.includes('/profile/update') && controllerContent.includes('/profile/change-password'); - - if (hasProfileGet && hasProfileUpdate && hasChangePassword && hasProfileRoutes) { - console.log('✅ 控制器方法已实现'); - console.log('✅ 路由配置已添加'); - } else { - console.log('❌ 控制器方法或路由配置不完整'); - } -} catch (error) { - console.log('❌ 无法读取控制器文件'); -} - -// 检查前端模板 -console.log('\n🎨 检查前端模板...'); -try { - const templateContent = fs.readFileSync('src/views/page/profile/index.pug', 'utf8'); - - const hasProfileForm = templateContent.includes('profileForm'); - const hasPasswordForm = templateContent.includes('passwordForm'); - const hasUserFields = templateContent.includes('username') && templateContent.includes('email') && templateContent.includes('name'); - const hasInlineStyles = templateContent.includes('style.') && templateContent.includes('.profile-container'); - - if (hasProfileForm && hasPasswordForm && hasUserFields && hasInlineStyles) { - console.log('✅ 前端模板包含必要表单和样式'); - } else { - console.log('❌ 前端模板缺少必要元素'); - } -} catch (error) { - console.log('❌ 无法读取前端模板文件'); -} - -// 检查JavaScript功能 -console.log('\n⚡ 检查JavaScript功能...'); -try { - const jsContent = fs.readFileSync('public/js/profile.js', 'utf8'); - - const hasProfileUpdate = jsContent.includes('handleProfileUpdate'); - const hasPasswordChange = jsContent.includes('handlePasswordChange'); - const hasValidation = jsContent.includes('validateField'); - const hasIIFE = jsContent.includes('(function()') && jsContent.includes('})();'); - - if (hasProfileUpdate && hasPasswordChange && hasValidation && hasIIFE) { - console.log('✅ JavaScript文件包含必要功能,使用IIFE模式'); - } else { - console.log('❌ JavaScript文件缺少必要功能'); - } -} catch (error) { - console.log('❌ 无法读取JavaScript文件'); -} - -console.log('\n📋 测试完成!'); -console.log('\n📝 下一步操作:'); -console.log('1. 运行数据库迁移: npm run migrate'); -console.log('2. 启动应用: npm start'); -console.log('3. 访问 /profile 页面测试功能'); -console.log('4. 确保用户已登录才能访问资料页面'); - -console.log('\n🔧 如果遇到问题:'); -console.log('- 检查数据库连接'); -console.log('- 确认用户表结构正确'); -console.log('- 查看浏览器控制台错误信息'); -console.log('- 检查服务器日志'); - -console.log('\n✨ 重构完成:'); -console.log('- 样式已内联到Pug模板中'); -console.log('- JavaScript使用IIFE模式,避免全局污染'); -console.log('- 界面设计更简洁,与项目风格保持一致'); -console.log('- 代码结构更清晰,易于维护'); diff --git a/src/controllers/Api/ApiController.js b/src/controllers/Api/ApiController.js index 73f5fb3..602e56e 100644 --- a/src/controllers/Api/ApiController.js +++ b/src/controllers/Api/ApiController.js @@ -1,4 +1,4 @@ -import { formatResponse } from "utils/helper.js" +import { R } from "utils/helper.js" import Router from "utils/router.js" class AuthController { @@ -40,7 +40,7 @@ class AuthController { ctx.set("Content-Type", "image/jpeg") ctx.body = data } else { - ctx.body = formatResponse(false, "Failed to fetch image") + R.ResponseJSON(R.ERROR, "Failed to fetch image") } } diff --git a/src/controllers/Api/AuthController.js b/src/controllers/Api/AuthController.js index 1d0586f..4c4e5cd 100644 --- a/src/controllers/Api/AuthController.js +++ b/src/controllers/Api/AuthController.js @@ -1,5 +1,5 @@ -import UserService from "services/UserService.js" -import { formatResponse } from "utils/helper.js" +import UserService from "services/userService.js" +import { R } from "utils/helper.js" import Router from "utils/router.js" class AuthController { @@ -8,24 +8,24 @@ class AuthController { } async hello(ctx) { - ctx.body = formatResponse(true, "Hello World") + R.ResponseJSON(R.SUCCESS,"Hello World") } async getUser(ctx) { const user = await this.userService.getUserById(ctx.params.id) - ctx.body = formatResponse(true, user) + R.ResponseJSON(R.SUCCESS,user) } async register(ctx) { const { username, email, password } = ctx.request.body const user = await this.userService.register({ username, email, password }) - ctx.body = formatResponse(true, user) + R.ResponseJSON(R.SUCCESS,user) } async login(ctx) { const { username, email, password } = ctx.request.body const result = await this.userService.login({ username, email, password }) - ctx.body = formatResponse(true, result) + R.ResponseJSON(R.SUCCESS,result) } /** diff --git a/src/controllers/Api/JobController.js b/src/controllers/Api/JobController.js index a4a3f0a..719fddf 100644 --- a/src/controllers/Api/JobController.js +++ b/src/controllers/Api/JobController.js @@ -1,6 +1,6 @@ // Job Controller 示例:如何调用 service 层动态控制和查询定时任务 import JobService from "services/JobService.js" -import { formatResponse } from "utils/helper.js" +import { R } from "utils/helper.js" import Router from "utils/router.js" class JobController { @@ -10,26 +10,26 @@ class JobController { async list(ctx) { const data = this.jobService.listJobs() - ctx.body = formatResponse(true, data) + R.ResponseJSON(R.SUCCESS,data) } async start(ctx) { const { id } = ctx.params this.jobService.startJob(id) - ctx.body = formatResponse(true, null, null, `${id} 任务已启动`) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务已启动`) } async stop(ctx) { const { id } = ctx.params this.jobService.stopJob(id) - ctx.body = formatResponse(true, null, null, `${id} 任务已停止`) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务已停止`) } async updateCron(ctx) { const { id } = ctx.params const { cronTime } = ctx.request.body this.jobService.updateJobCron(id, cronTime) - ctx.body = formatResponse(true, null, null, `${id} 任务频率已修改`) + R.ResponseJSON(R.SUCCESS,null, `${id} 任务频率已修改`) } static createRoutes() { diff --git a/src/controllers/Page/PageController.js b/src/controllers/Page/PageController.js index 2eabc2a..793975c 100644 --- a/src/controllers/Page/PageController.js +++ b/src/controllers/Page/PageController.js @@ -1,10 +1,15 @@ import Router from "utils/router.js" -import UserService from "services/UserService.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" class PageController { constructor() { @@ -27,7 +32,7 @@ class PageController { url: "https://pic.xieyaxin.top/random.php", }, ], - blogs: blogs.slice(0, 4) + blogs: blogs.slice(0, 4), }, { includeSite: true, includeUser: true } ) @@ -130,13 +135,17 @@ class PageController { 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 }) + return await ctx.render( + "page/profile/index", + { + user, + site_title: "用户资料", + }, + { includeSite: true, includeUser: true } + ) } catch (error) { logger.error(`获取用户资料失败: ${error.message}`) ctx.status = 500 @@ -154,7 +163,7 @@ class PageController { try { const { username, email, name, bio, avatar } = ctx.request.body - + // 验证必填字段 if (!username) { ctx.status = 400 @@ -163,23 +172,23 @@ class PageController { } const updateData = { username, email, name, bio, avatar } - + // 移除空值 Object.keys(updateData).forEach(key => { - if (updateData[key] === undefined || updateData[key] === null || updateData[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, + + ctx.body = { + success: true, message: "资料更新成功", - user: updatedUser + user: updatedUser, } } catch (error) { logger.error(`更新用户资料失败: ${error.message}`) @@ -198,7 +207,7 @@ class PageController { try { const { oldPassword, newPassword, confirmPassword } = ctx.request.body - + if (!oldPassword || !newPassword || !confirmPassword) { ctx.status = 400 ctx.body = { success: false, message: "请填写所有密码字段" } @@ -218,10 +227,10 @@ class PageController { } await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword) - - ctx.body = { - success: true, - message: "密码修改成功" + + ctx.body = { + success: true, + message: "密码修改成功", } } catch (error) { logger.error(`修改密码失败: ${error.message}`) @@ -230,10 +239,167 @@ class PageController { } } + // 支持多文件上传,支持图片、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 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()}${safeExt}` + const destPath = path.join(avatarsDir, filename) + + // 如果设置了 uploadDir 且 keepExtensions,文件可能已在该目录,仍统一重命名 + if (oldPath && oldPath !== destPath) { + await fs.rename(oldPath, destPath) + } + + const url = `/uploads/avatars/${filename}` + + 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, + 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 @@ -244,10 +410,10 @@ class PageController { // 这里可以添加邮件发送逻辑或数据库存储逻辑 // 目前只是简单的成功响应 logger.info(`收到联系表单: ${name} (${email}) - ${subject}: ${message}`) - - ctx.body = { - success: true, - message: "感谢您的留言,我们会尽快回复您!" + + ctx.body = { + success: true, + message: "感谢您的留言,我们会尽快回复您!", } } @@ -283,6 +449,7 @@ class PageController { 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 }) diff --git a/src/global.js b/src/global.js index df9d66a..ba82f63 100644 --- a/src/global.js +++ b/src/global.js @@ -1,7 +1,7 @@ import Koa from "koa" import { logger } from "./logger.js" -const app = new Koa() +const app = new Koa({ asyncLocalStorage: true }) app.keys = [] diff --git a/src/middlewares/Views/index.js b/src/middlewares/Views/index.js index 6fc9682..8250bf6 100644 --- a/src/middlewares/Views/index.js +++ b/src/middlewares/Views/index.js @@ -1,4 +1,5 @@ import { resolve } from "path" +import { app } from "@/global" import consolidate from "consolidate" import send from "../Send" import getPaths from "get-paths" diff --git a/src/utils/helper.js b/src/utils/helper.js index a1903ee..ffa829b 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -1,4 +1,26 @@ +import { app } from "@/global" -export function formatResponse(success, data = null, error = null) { - return { success, error, data } +function ResponseSuccess(data = null, message = null) { + return { success: true, error: message, data } } + +function ResponseError(data = null, message = null) { + return { success: false, error: message, data } +} + +function ResponseJSON(statusCode = 200, data = null, message = null) { + app.currentContext.status = statusCode + return (app.currentContext.body = { success: true, error: message, data }) +} + +const R = { + ResponseSuccess, + ResponseError, + ResponseJSON, +} + +R.SUCCESS = 200 +R.ERROR = 500 +R.NOTFOUND = 404 + +export { R }