chore: initial import

This commit is contained in:
2026-04-19 14:05:40 +08:00
commit 89e9eae36e
81 changed files with 37363 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
const fs = require('fs');
const path = require('path');
const db = require('../db');
const logger = require('./logger');
const parsePositiveNumber = (value, fallback) => {
const num = Number(value);
return Number.isFinite(num) && num > 0 ? num : fallback;
};
// 磁盘告警阈值(GB),默认 10 GB
const THRESHOLD_GB = parsePositiveNumber(process.env.DISK_ALERT_THRESHOLD_GB, 10);
const THRESHOLD_BYTES = THRESHOLD_GB * 1024 * 1024 * 1024;
// 告警冷却时间(小时),默认 24 小时
const ALERT_COOLDOWN_HOURS = parsePositiveNumber(process.env.DISK_ALERT_COOLDOWN_HOURS, 24);
const getDiskSpace = async (directoryPath) => {
try {
// 仅在支持 statfs 的 Node 版本启用
if (fs.promises.statfs) {
const stats = await fs.promises.statfs(directoryPath);
const FreeSpace = stats.bfree * stats.bsize;
const Size = stats.blocks * stats.bsize;
return { FreeSpace, Size };
}
logger.warn('当前 Node.js 版本不支持 fs.promises.statfs,跳过磁盘检查');
return { FreeSpace: Number.MAX_SAFE_INTEGER, Size: Number.MAX_SAFE_INTEGER };
} catch (error) {
logger.error(`获取磁盘空间失败 [${directoryPath}]: ${error.message}`);
throw error;
}
};
const checkDiskSpace = async () => {
try {
// 1. 获取上传目录绝对路径
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
const absolutePath = path.resolve(uploadDir);
// 目录不存在时,回退到所在盘符根目录进行检测
let targetPath = absolutePath;
try {
await fs.promises.access(targetPath);
} catch {
targetPath = path.parse(absolutePath).root;
}
// 2. 获取磁盘空间
const { FreeSpace } = await getDiskSpace(targetPath);
// 3. 判断是否低于阈值
if (FreeSpace < THRESHOLD_BYTES) {
const root = path.parse(absolutePath).root;
const freeGB = (FreeSpace / (1024 * 1024 * 1024)).toFixed(2);
const title = `【系统警告】磁盘空间不足 (${root})`;
// 4. 冷却期内同标题只发一次
const cooldownTime = new Date(Date.now() - ALERT_COOLDOWN_HOURS * 60 * 60 * 1000);
const recentAlert = await db.query(
`SELECT id FROM notifications
WHERE title = $1
AND created_at > $2
LIMIT 1`,
[title, cooldownTime]
);
if (recentAlert.rows.length === 0) {
logger.warn(`磁盘空间不足警告: ${root} 剩余 ${freeGB} GB`);
const content = `系统检测到存储磁盘 (${root}) 剩余空间仅剩 ${freeGB} GB,低于警告阈值 (${THRESHOLD_GB} GB)。\n\n请系统管理员尽快处理:\n1. 清理磁盘空间。\n2. 或配置新的存储路径(如挂载大容量磁盘),并更新配置文件中的 UPLOAD_DIR 参数。`;
// 尝试获取 ID 最小的 super_admin 作为发布者
let publisherId = 1;
const adminRes = await db.query("SELECT id FROM users WHERE role = 'super_admin' ORDER BY id ASC LIMIT 1");
if (adminRes.rows.length > 0) {
publisherId = adminRes.rows[0].id;
}
await db.query(
'INSERT INTO notifications (title, content, publisher_id, publisher_name, is_top, created_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)',
[title, content, publisherId, '系统监控', true]
);
logger.info('已发布磁盘空间不足警告通知');
}
}
} catch (error) {
logger.error(`磁盘空间检查失败: ${error.message}`);
}
};
module.exports = { checkDiskSpace };
+55
View File
@@ -0,0 +1,55 @@
const winston = require('winston');
require('winston-daily-rotate-file');
const path = require('path');
// 定义日志目录
const logDir = path.join(__dirname, '../logs');
// 定义日志格式
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message }) => {
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
})
);
// 创建 Logger 实例
const logger = winston.createLogger({
format: logFormat,
transports: [
// 1. 错误日志:只记录 error 级别
new winston.transports.DailyRotateFile({
filename: path.join(logDir, 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
maxSize: '20m',
maxFiles: '14d', // 保留14天
}),
// 2. 综合日志:记录所有级别
new winston.transports.DailyRotateFile({
filename: path.join(logDir, 'combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
maxSize: '20m',
maxFiles: '14d',
}),
],
});
// 如果不是生产环境,也输出到控制台
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
// 创建一个流对象,供 morgan 使用
logger.stream = {
write: (message) => {
logger.info(message.trim());
},
};
module.exports = logger;
+33
View File
@@ -0,0 +1,33 @@
const Dypnsapi20170525 = require('@alicloud/dypnsapi20170525');
const OpenApi = require('@alicloud/openapi-client');
const Util = require('@alicloud/tea-util');
class SmsClient {
static createClient() {
let config = new OpenApi.Config({
accessKeyId: process.env.ALIBABA_CLOUD_ACCESS_KEY_ID,
accessKeySecret: process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
});
config.endpoint = `dypnsapi.aliyuncs.com`;
return new Dypnsapi20170525.default(config);
}
static async sendVerifyCode(phoneNumber, code) {
let client = this.createClient();
let sendSmsVerifyCodeRequest = new Dypnsapi20170525.SendSmsVerifyCodeRequest({
signName: process.env.SMS_SIGN_NAME,
templateCode: process.env.SMS_TEMPLATE_CODE,
phoneNumber: phoneNumber,
templateParam: `{"code":"${code}","min":"5"}`,
});
let runtime = new Util.RuntimeOptions({});
try {
return await client.sendSmsVerifyCodeWithOptions(sendSmsVerifyCodeRequest, runtime);
} catch (error) {
console.error('短信发送失败:', error.message);
throw error;
}
}
}
module.exports = SmsClient;