Browse Source

更新依赖项,新增文件上传功能,重构 API 控制器以使用统一的响应格式,优化用户资料管理,删除不再使用的测试脚本

re
谢亚昕 3 months ago
parent
commit
235f109b4b
  1. BIN
      bun.lockb
  2. BIN
      database/development.sqlite3-shm
  3. BIN
      database/development.sqlite3-wal
  4. 1
      package.json
  5. 0
      public/uploads/avatars/.gitkeep
  6. 0
      public/uploads/files/.gitkeep
  7. 129
      scripts/test-profile.js
  8. 4
      src/controllers/Api/ApiController.js
  9. 12
      src/controllers/Api/AuthController.js
  10. 10
      src/controllers/Api/JobController.js
  11. 185
      src/controllers/Page/PageController.js
  12. 2
      src/global.js
  13. 1
      src/middlewares/Views/index.js
  14. 26
      src/utils/helper.js

BIN
bun.lockb

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

1
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",

0
public/uploads/avatars/.gitkeep

0
public/uploads/files/.gitkeep

129
scripts/test-profile.js

@ -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('- 代码结构更清晰,易于维护');

4
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")
}
}

12
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)
}
/**

10
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() {

185
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 }
)
@ -133,10 +138,14 @@ class PageController {
try {
const user = await this.userService.getUserById(ctx.session.user.id)
return await ctx.render("page/profile/index", {
return await ctx.render(
"page/profile/index",
{
user,
site_title: "用户资料"
}, { includeSite: true, includeUser: true })
site_title: "用户资料",
},
{ includeSite: true, includeUser: true }
)
} catch (error) {
logger.error(`获取用户资料失败: ${error.message}`)
ctx.status = 500
@ -166,7 +175,7 @@ class PageController {
// 移除空值
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]
}
})
@ -179,7 +188,7 @@ class PageController {
ctx.body = {
success: true,
message: "资料更新成功",
user: updatedUser
user: updatedUser,
}
} catch (error) {
logger.error(`更新用户资料失败: ${error.message}`)
@ -221,7 +230,7 @@ class PageController {
ctx.body = {
success: true,
message: "密码修改成功"
message: "密码修改成功",
}
} catch (error) {
logger.error(`修改密码失败: ${error.message}`)
@ -230,6 +239,163 @@ 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
@ -247,7 +413,7 @@ class PageController {
ctx.body = {
success: true,
message: "感谢您的留言,我们会尽快回复您!"
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 })

2
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 = []

1
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"

26
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 }

Loading…
Cancel
Save