From c50216606e6af4914b255cbc731c5e67273b663c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=A5=E5=AE=B6=E8=BE=89?= Date: Sat, 6 Jul 2024 23:57:05 +0800 Subject: [PATCH] feat(files): #5 File Recycle Bin Function --- index.js | 2 +- models/files.js | 12 +++++- package.json | 1 + routers/files.js | 44 ++++++++++--------- schedules/fileRecover.js | 92 ++++++++++++++++++++++++++++++++++++++++ yarn.lock | 17 +++++--- 6 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 schedules/fileRecover.js diff --git a/index.js b/index.js index 2400f84..90eb979 100755 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const redisClient = require("./redis"); const authenticateToken = require("./middleware/authenticateToken"); const cors = require("@koa/cors"); require("./models"); +require("./schedules/fileRecover"); require("dotenv").config({ path: ".env.local" }); const app = new Koa(); @@ -59,5 +60,4 @@ app.listen(process.env.SERVER_PORT, async () => { await redisClient.connect(); await sequelize.sync(); console.log(`Server is running on ${process.env.INTERNAL_NETWORK_DOMAIN}`); - console.log(`Server is running on ${process.env.PUBLIC_NETWORK_DOMAIN}`); }); diff --git a/models/files.js b/models/files.js index c9bcff5..a38dfed 100644 --- a/models/files.js +++ b/models/files.js @@ -66,11 +66,20 @@ const Files = sequelize.define( allowNull: true, defaultValue: null, }, - is_delete: { + is_deleted: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, }, + deleted_at: { + type: DataTypes.DATE, + allowNull: true, + }, + deleted_by: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + }, real_file_location: { type: DataTypes.STRING(255), allowNull: true, @@ -96,6 +105,7 @@ const Files = sequelize.define( tableName: "files", timestamps: false, underscored: true, + paranoid: true, charset: "utf8mb4", collate: "utf8mb4_general_ci", } diff --git a/package.json b/package.json index 578ae52..e70db83 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "koa-static": "^5.0.0", "koa2-cors": "^2.0.6", "mysql2": "^3.10.1", + "node-cron": "^3.0.3", "nodemon": "^3.1.4", "path-to-regexp": "^7.0.0", "pm2": "^5.4.0", diff --git a/routers/files.js b/routers/files.js index c7f5552..575c0e1 100644 --- a/routers/files.js +++ b/routers/files.js @@ -128,7 +128,7 @@ router.post( is_public: isFilePublic, thumb_location: thumbUrl, is_thumb: shouldGenerateThumb, - is_delete: false, + is_deleted: false, real_file_thumb_location: realThumbPath, mime, ext: fileExt, @@ -200,7 +200,7 @@ router.get("/files", validateQuery(FILES_LIST_GET_QUERY), async (ctx) => { const { rows, count } = await Files.findAndCountAll({ where: { - is_delete: false, + is_deleted: false, [Op.or]: [ { public_expiration: null, is_public: true }, { public_expiration: { [Op.gt]: new Date() }, is_public: true }, @@ -250,7 +250,7 @@ router.get("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const file = await Files.findOne({ where: { id, - is_delete: false, + is_deleted: false, [Op.or]: [ { public_expiration: null, is_public: true }, { public_expiration: { [Op.gt]: new Date() }, is_public: true }, @@ -301,7 +301,7 @@ router.put("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const file = await Files.findOne({ where: { id, - is_delete: false, + is_deleted: false, created_by: ctx.state.user.id, }, }); @@ -338,12 +338,14 @@ router.delete("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { const { id } = ctx.params; try { + const deleted_by = ctx.state.user.id; // 查找文件 const file = await Files.findOne({ where: { id, - is_delete: false, - created_by: ctx.state.user.id, + is_deleted: false, + deleted_at: new Date(), + deleted_by, }, }); @@ -353,11 +355,11 @@ router.delete("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { return; } - // 执行软删除,将 is_delete 字段设置为 true + // 执行软删除,将 is_deleted 字段设置为 true await file.update({ - is_delete: true, - updated_at: new Date(), // 更新更新时间 - updated_by: ctx.query.updated_by || "anonymous", // 可以通过查询参数传递更新者 + is_deleted: true, + deleted_at: new Date(), // 更新更新时间 + deleted_by, // 可以通过查询参数传递更新者 }); // 返回删除成功的信息 @@ -372,7 +374,7 @@ router.delete("/files/:id", validateParams(FILES_REST_ID), async (ctx) => { // 文件批量删除接口 router.delete("/files", validateBody(FILES_BODY_BATCH_IDS), async (ctx) => { const { ids } = ctx.request.body; - const updated_by = ctx.state.user.id; + const deleted_by = ctx.state.user.id; if (!ids || !Array.isArray(ids) || ids.length === 0) { ctx.status = 400; @@ -384,17 +386,21 @@ router.delete("/files", validateBody(FILES_BODY_BATCH_IDS), async (ctx) => { // 查找并更新指定的文件 const [numberOfAffectedRows] = await Files.update( { - is_delete: true, - updated_by: updated_by, - updated_at: new Date(), + is_deleted: true, + deleted_by, + deleted_at: new Date(), }, { where: { id: { [Op.in]: ids, }, - created_by: ctx.state.user.id, - is_delete: false, + is_deleted: false, + [Op.or]: [ + { + created_by: deleted_by, + }, + ], }, } ); @@ -422,7 +428,7 @@ router.get("/files/:id/preview", validateParams(FILES_REST_ID), async (ctx) => { const file = await Files.findOne({ where: { id, - is_delete: false, + is_deleted: false, [Op.or]: [ { public_expiration: null, is_public: true }, { public_expiration: { [Op.gt]: new Date() }, is_public: true }, @@ -487,7 +493,7 @@ router.get( const file = await Files.findOne({ where: { id: id, - is_delete: false, + is_deleted: false, [Op.or]: [ { public_expiration: null, is_public: true }, { public_expiration: { [Op.gt]: new Date() }, is_public: true }, @@ -550,7 +556,7 @@ router.post( const files = await Files.findAll({ where: { id: { [Op.in]: ids }, - is_delete: false, + is_deleted: false, [Op.or]: [ { public_expiration: null, is_public: true }, { public_expiration: { [Op.gt]: new Date() }, is_public: true }, diff --git a/schedules/fileRecover.js b/schedules/fileRecover.js new file mode 100644 index 0000000..1869ebd --- /dev/null +++ b/schedules/fileRecover.js @@ -0,0 +1,92 @@ +const cron = require("node-cron"); +const { Op } = require("sequelize"); +const fs = require("fs").promises; +const Files = require("../models/files"); +const Users = require("../models/users"); + +// 定时任务每天22:52分运行一次 +cron.schedule("52 23 * * *", async () => { + const sevenDaysAgo = new Date(new Date() - 7 * 24 * 60 * 60 * 1000); + + try { + // 查找需要删除的记录 + const records = await Files.findAll({ + where: { + is_deleted: true, + deleted_at: { + [Op.lt]: sevenDaysAgo, // 查找 deletedAt 字段小于七天前的记录 + }, + }, + }); + + // 收集所有文件删除和用户容量更新的Promise + const fileDeletePromises = []; + const userUpdates = {}; + + for (const record of records) { + const { + real_file_location, + real_file_thumb_location, + created_by, + file_size, + } = record; + + if (real_file_location) { + fileDeletePromises.push( + fs.unlink(real_file_location).catch((err) => { + console.error(`Failed to delete file: ${real_file_location}`, err); + }) + ); + } + + if (real_file_thumb_location) { + fileDeletePromises.push( + fs.unlink(real_file_thumb_location).catch((err) => { + console.error( + `Failed to delete thumbnail: ${real_file_thumb_location}`, + err + ); + }) + ); + } + + if (userUpdates[created_by]) { + userUpdates[created_by] -= file_size; + } else { + userUpdates[created_by] = + ( + await Users.findOne({ + where: { id: created_by }, + }) + ).used_capacity - file_size; + } + } + + // 批量删除文件 + await Promise.all(fileDeletePromises); + + // 批量更新用户容量 + const userUpdatePromises = []; + for (const [userId, newCapacity] of Object.entries(userUpdates)) { + userUpdatePromises.push( + Users.update({ used_capacity: newCapacity }, { where: { id: userId } }) + ); + } + await Promise.all(userUpdatePromises); + + // 真实删除数据库记录 + await Files.destroy({ + where: { + is_deleted: true, + deleted_at: { + [Op.lt]: sevenDaysAgo, + }, + }, + force: true, // 真实删除 + }); + + console.log("Old records and associated files deleted successfully."); + } catch (error) { + console.error("Error deleting old records and associated files:", error); + } +}); diff --git a/yarn.lock b/yarn.lock index 35cad98..8bd2700 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2589,6 +2589,13 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== +node-cron@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.3.tgz#c4bc7173dd96d96c50bdb51122c64415458caff2" + integrity sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A== + dependencies: + uuid "8.3.2" + node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -3735,16 +3742,16 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@8.3.2, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^10.0.0: version "10.0.0" resolved "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"