diff --git a/package.json b/package.json index 3f1924b..6217d8c 100755 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "axios": "^1.7.2", "dotenv": "^16.4.5", "file-type": "^19.0.0", + "joi": "^17.13.3", "jszip": "^3.10.1", "koa": "^2.15.3", "koa-body": "^6.0.1", diff --git a/routers/files.js b/routers/files.js index 0869d65..71d2116 100644 --- a/routers/files.js +++ b/routers/files.js @@ -13,6 +13,8 @@ const { imageMimeTypes, tinifySupportedMimeTypes, } = require("../constants/file"); +const { FILES_UPLOAD_POST_QUERY, FILES_LIST_GET_QUERY, FILES_REST_ID, FILES_BODY_BATCH_IDS } = require("../types/schema/files"); +const { validateQuery, validateBody, validateFormData, validateParams } = require("../types"); tinify.key = process.env.TINIFY_KEY; @@ -44,17 +46,17 @@ const getDefaultThumbPath = (mime) => { }; // 处理文件上传 -router.post("/files", async (ctx) => { +router.post("/files", validateFormData, validateQuery(FILES_UPLOAD_POST_QUERY), async (ctx) => { try { const files = ctx.request.files.file; const fileList = Array.isArray(files) ? files : [files]; const responses = []; + const { compress, keepTemp, isThumb, isPublic, type: responseType } = ctx.query; - const shouldCompress = ctx.query.compress !== "false"; - const shouldKeepTemp = ctx.query.keepTemp === "true"; - const shouldGenerateThumb = ctx.query.isThumb === "true"; - const isFilePublic = ctx.query.isPublic === "true"; - const responseType = ctx.query.type; + const shouldCompress = compress === 'true'; + const shouldKeepTemp = keepTemp === 'true'; + const shouldGenerateThumb = isThumb === 'true'; + const isFilePublic = isPublic === 'true'; for (const file of fileList) { const fileId = uuidv4(); @@ -65,16 +67,20 @@ router.post("/files", async (ctx) => { let realThumbPath = null; if (shouldGenerateThumb && imageMimeTypes.includes(mime)) { + console.time('thumb') realThumbPath = getRealThumbPath(fileId); await sharp(file.filepath) .resize(200, 200) .toFile(realThumbPath); + console.timeEnd('thumb'); } else if (shouldGenerateThumb) { realThumbPath = getDefaultThumbPath(mime); } if (shouldCompress && tinifySupportedMimeTypes.includes(mime)) { + console.time('compress') await tinify.fromFile(file.filepath).toFile(realFilePath); + console.timeEnd('compress') } else { if (shouldKeepTemp) { await fsp.copyFile(file.filepath, realFilePath); @@ -131,7 +137,8 @@ router.post("/files", async (ctx) => { }); // 获取文件列表 -router.get("/files", async (ctx) => { +router.get("/files", validateQuery(FILES_LIST_GET_QUERY), async (ctx) => { + console.log(ctx.query); try { const limit = parseInt(ctx.query.limit, 10) || 10; // 每页数量,默认为 10 const offset = parseInt(ctx.query.offset, 10) || 0; // 偏移量,默认为 0 @@ -199,7 +206,7 @@ router.get("/files", async (ctx) => { }); // 获取单个文件信息 -router.get("/files/:id", async (ctx) => { +router.get("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; try { @@ -248,7 +255,7 @@ router.get("/files/:id", async (ctx) => { }); // 编辑文件信息接口 -router.put('/files/:id', async (ctx) => { +router.put("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; const { filename, @@ -264,12 +271,12 @@ router.put('/files/:id', async (ctx) => { where: { id, is_delete: false, - } + }, }); if (!file) { ctx.status = 404; - ctx.body = { message: 'File not found' }; + ctx.body = { message: "File not found" }; return; } @@ -304,13 +311,16 @@ router.put('/files/:id', async (ctx) => { ctx.body = updatedFile; } catch (error) { ctx.status = 500; - ctx.body = { message: 'Error updating file information', error: error.message }; - console.error('Update file error:', error); + ctx.body = { + message: "Error updating file information", + error: error.message, + }; + console.error("Update file error:", error); } }); // 文件删除接口 -router.delete('/files/:id', async (ctx) => { +router.delete("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; try { @@ -319,12 +329,12 @@ router.delete('/files/:id', async (ctx) => { where: { id, is_delete: false, - } + }, }); if (!file) { ctx.status = 404; - ctx.body = { message: 'File not found' }; + ctx.body = { message: "File not found" }; return; } @@ -332,35 +342,35 @@ router.delete('/files/:id', async (ctx) => { await file.update({ is_delete: true, updated_at: new Date(), // 更新更新时间 - updated_by: ctx.query.updated_by || 'anonymous' // 可以通过查询参数传递更新者 + updated_by: ctx.query.updated_by || "anonymous", // 可以通过查询参数传递更新者 }); // 返回删除成功的信息 ctx.status = 204; } catch (error) { ctx.status = 500; - ctx.body = { message: 'Error deleting file', error: error.message }; - console.error('Delete file error:', error); + ctx.body = { message: "Error deleting file", error: error.message }; + console.error("Delete file error:", error); } }); // 文件批量删除接口 -router.delete('/files', async (ctx) => { +router.delete("/files", validateBody(FILES_BODY_BATCH_IDS), async (ctx) => { const { ids } = ctx.request.body; // 获取要删除的文件 ID 列表 - const updated_by = ctx.query.updated_by || 'anonymous'; // 获取更新者,默认为匿名 + const updated_by = ctx.query.updated_by || "anonymous"; // 获取更新者,默认为匿名 console.log(ctx.request.body); console.log(JSON.stringify(ctx.request.body)); if (!ids || !Array.isArray(ids) || ids.length === 0) { ctx.status = 400; - ctx.body = { message: 'No file ids provided for deletion' }; + ctx.body = { message: "No file ids provided for deletion" }; return; } try { // 查找并更新指定的文件 const [numberOfAffectedRows] = await Files.update( - { + { is_delete: true, updated_by: updated_by, updated_at: new Date(), @@ -377,7 +387,7 @@ router.delete('/files', async (ctx) => { if (numberOfAffectedRows === 0) { ctx.status = 404; - ctx.body = { message: 'No files found to delete' }; + ctx.body = { message: "No files found to delete" }; return; } @@ -385,13 +395,13 @@ router.delete('/files', async (ctx) => { ctx.status = 204; } catch (error) { ctx.status = 500; - ctx.body = { message: 'Error deleting files', error: error.message }; - console.error('Delete files error:', error); + ctx.body = { message: "Error deleting files", error: error.message }; + console.error("Delete files error:", error); } }); // 文件预览 -router.get("/files/:id/preview", async (ctx) => { +router.get("/files/:id/preview", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; const { type } = ctx.query; // 获取查询参数 'type',可以是 'thumb' 或 'original' @@ -454,7 +464,7 @@ router.get("/files/:id/preview", async (ctx) => { }); // 单文件下载 -router.get("/files/:id/export", async (ctx) => { +router.get("/files/:id/download", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; try { @@ -506,8 +516,8 @@ router.get("/files/:id/export", async (ctx) => { }); // 批量下载 -router.get("/files/export/batch", async (ctx) => { - const ids = ctx.query.ids ? ctx.query.ids.split(",") : []; +router.post("/files/download", validateBody(FILES_BODY_BATCH_IDS), async (ctx) => { + const ids = ctx.request.body.ids; if (ids.length === 0) { ctx.status = 400; diff --git a/types/index.js b/types/index.js new file mode 100644 index 0000000..84db5fa --- /dev/null +++ b/types/index.js @@ -0,0 +1,88 @@ +// 生成用于校验 query 参数的中间件 +function validateQuery(schema) { + return async function(ctx, next) { + try { + const validated = await schema.validateAsync(ctx.query, { + allowUnknown: true, + convert: true, + stripUnknown: true + }); + ctx.query = validated; + await next(); + } catch (err) { + console.log(err); + + ctx.status = 400; + ctx.body = { + message: "Query Validation Error", + error: err.details[0].message, + }; + } + }; +} + +// 生成用于校验 body 数据的中间件 +function validateBody(schema) { + return async function(ctx, next) { + try { + const validated = await schema.validateAsync(ctx.request.body, { + allowUnknown: true, + convert: true, + }); + ctx.request.body = validated; + await next(); + } catch (err) { + ctx.status = 400; + ctx.body = { + message: "Body Validation Error", + error: err.details[0].message, + }; + } + }; +} + +async function validateFormData(ctx, next) { + try { + const files = ctx.request.files ? ctx.request.files.file : null; + + // 检查是否上传了文件 + if (!files) { + ctx.status = 400; + ctx.body = { message: "File upload is required." }; + return; + } + + await next(); + } catch (err) { + ctx.status = 400; + ctx.body = { message: 'Validation Error', error: err.message }; + return; + } +} + +function validateParams(schema) { + return async function(ctx, next) { + try { + const validated = await schema.validateAsync(ctx.params, { + allowUnknown: true, + convert: true, + stripUnknown: true + }); + ctx.params = validated; + await next(); + } catch (err) { + ctx.status = 400; + ctx.body = { + message: "Params Validation Error", + error: err.details[0].message, + }; + } + }; +} + +module.exports = { + validateBody, + validateQuery, + validateFormData, + validateParams +}; diff --git a/types/schema/files.js b/types/schema/files.js new file mode 100644 index 0000000..da54052 --- /dev/null +++ b/types/schema/files.js @@ -0,0 +1,46 @@ +const Joi = require("joi"); + +/** + 业务相关定义 = model business method format + */ +const FILES_UPLOAD_POST_QUERY = Joi.object({ + compress: Joi.string() + .valid("true", "false").default("false"), + keepTemp: Joi.string() + .valid("true", "false").default("false"), + isThumb: Joi.string() + .valid("true", "false").default("true"), + isPublic: Joi.string() + .valid("true", "false").default("false"), + type: Joi.string() + .valid("md", "url").required() +}); + +const FILES_LIST_GET_QUERY = Joi.object({ + limit: Joi.number().integer().min(1).max(100).default(10), + offset: Joi.number().integer().min(0).default(0), + type: Joi.string().valid('image', 'video', 'file', 'all').default('all') +}); + + +/** + 通用定义 = model format fields + */ + +const FILES_REST_ID = Joi.object({ + id: Joi.string().required() +}); + +const FILES_BODY_BATCH_IDS = Joi.object({ + ids: Joi.array() + .items(Joi.string().required()) + .required() + .min(1) +}); + +module.exports = { + FILES_UPLOAD_POST_QUERY, + FILES_LIST_GET_QUERY, + FILES_REST_ID, + FILES_BODY_BATCH_IDS +}; diff --git a/yarn.lock b/yarn.lock index d30c528..7f5c426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,18 @@ resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@koa/cors@^5.0.0": version "5.0.0" resolved "https://registry.npmmirror.com/@koa/cors/-/cors-5.0.0.tgz" @@ -65,6 +77,23 @@ dependencies: debug "^4.3.1" +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -1130,6 +1159,17 @@ isarray@~1.0.0: resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== +joi@^17.13.3: + version "17.13.3" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec" + integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + js-git@^0.7.8: version "0.7.8" resolved "https://registry.npmmirror.com/js-git/-/js-git-0.7.8.tgz"