init
This commit is contained in:
17
server/.env
Normal file
17
server/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
76
server/README.md
Normal file
76
server/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Jnote Server
|
||||
|
||||
博客后端 API 服务
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `.env` 文件,填入数据库连接信息:
|
||||
|
||||
```
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=jnote
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
如果不配置数据库,将使用内存存储作为后备方案。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm start
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### 文章
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/posts` | GET | 获取所有文章 |
|
||||
| `/api/posts/:id` | GET | 获取单篇文章 |
|
||||
| `/api/posts` | POST | 创建文章 |
|
||||
| `/api/posts/:id` | PUT | 更新文章 |
|
||||
| `/api/posts/:id` | DELETE | 删除文章 |
|
||||
|
||||
### 关于
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/about` | GET | 获取关于内容 |
|
||||
| `/api/about` | PUT | 更新关于内容 |
|
||||
|
||||
### 设置
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/settings` | GET | 获取设置 |
|
||||
| `/api/settings` | PUT | 更新设置 |
|
||||
|
||||
### 定时任务
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/cron-tasks` | GET | 获取所有任务 |
|
||||
| `/api/cron-tasks` | POST | 创建任务 |
|
||||
| `/api/cron-tasks/:id` | PUT | 更新任务 |
|
||||
| `/api/cron-tasks/:id` | DELETE | 删除任务 |
|
||||
| `/api/cron-tasks/:id/run` | POST | 立即执行任务 |
|
||||
|
||||
### 健康检查
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/health` | GET | 服务健康状态 |
|
||||
89
server/build.js
Normal file
89
server/build.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* JNote 后端打包脚本
|
||||
* 支持 Node.js 16.x
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const serverDir = __dirname;
|
||||
|
||||
// Node 16.x -> pkg 5.8.1
|
||||
const PKG_VERSION = '5.8.1';
|
||||
const TARGET_NODE_VERSION = 'node16';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Build] ${msg}`);
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
log(`执行: ${cmd}`);
|
||||
execSync(cmd, { cwd: serverDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function checkNodeVersion() {
|
||||
const version = execSync('node --version', { encoding: 'utf8' }).trim();
|
||||
const match = version.match(/^v(\d+)\./);
|
||||
if (match) {
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端打包工具');
|
||||
console.log(` 当前平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
// 检查 Node.js 环境
|
||||
const nodeMajor = checkNodeVersion();
|
||||
if (!nodeMajor) {
|
||||
console.error('❌ 错误: 未找到 Node.js');
|
||||
process.exit(1);
|
||||
}
|
||||
log(`✓ Node.js 版本: v${nodeMajor}.x`);
|
||||
|
||||
// 安装依赖
|
||||
console.log('');
|
||||
log('📦 安装依赖...');
|
||||
run('npm install');
|
||||
|
||||
// 安装 pkg
|
||||
try {
|
||||
execSync('pkg --version', { encoding: 'utf8', stdio: 'ignore' });
|
||||
log('✓ pkg 已安装');
|
||||
} catch {
|
||||
console.log('');
|
||||
log(`📦 安装 pkg ${PKG_VERSION}...`);
|
||||
run(`npm install -g pkg@${PKG_VERSION}`);
|
||||
}
|
||||
|
||||
// 创建 dist 目录
|
||||
const distDir = path.join(serverDir, 'dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 打包 Linux 版本
|
||||
console.log('');
|
||||
log(`🔨 打包 Linux 版本 (${TARGET_NODE_VERSION}-linux-x64)...`);
|
||||
run(`pkg src/index.js --targets ${TARGET_NODE_VERSION}-linux-x64 --output dist/jnote-api`);
|
||||
|
||||
console.log('');
|
||||
console.log('=========================================');
|
||||
console.log(' 打包完成!');
|
||||
console.log('=========================================');
|
||||
console.log('');
|
||||
console.log('输出文件:');
|
||||
console.log(' - dist/jnote-api (Linux 可执行文件)');
|
||||
console.log('');
|
||||
console.log('下一步:');
|
||||
console.log(' 1. 将 dist/ 目录下的文件上传到服务器');
|
||||
console.log(' 2. 修改 .env 配置数据库等信息');
|
||||
console.log(' 3. 运行: chmod +x jnote-api && ./jnote-api');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main();
|
||||
5732
server/package-lock.json
generated
Normal file
5732
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
193
server/package.js
Normal file
193
server/package.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* JNote 后端分发包打包脚本
|
||||
* 在 Windows 上运行,打包 Linux 和 Windows 两个版本
|
||||
* 优化:Linux 生成 sh 脚本,Windows 生成 bat 脚本,原生直接执行
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const serverDir = __dirname;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Package] ${msg}`);
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
log(`执行: ${cmd}`);
|
||||
execSync(cmd, { cwd: serverDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端分发包打包工具');
|
||||
console.log(` 当前平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
// 检查 Node.js 环境
|
||||
try {
|
||||
const nodeVersion = execSync('node --version', { encoding: 'utf8' }).trim();
|
||||
log(`✓ Node.js 版本: ${nodeVersion}`);
|
||||
} catch {
|
||||
console.error('❌ 错误: 未找到 Node.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
console.log('');
|
||||
log('📦 安装依赖...');
|
||||
run('npm install');
|
||||
|
||||
// 安装 pkg (如果未安装)
|
||||
try {
|
||||
execSync('pkg --version', { encoding: 'utf8', stdio: 'ignore' });
|
||||
log('✓ pkg 已安装');
|
||||
} catch {
|
||||
console.log('');
|
||||
log('📦 安装 pkg...');
|
||||
run('npm install -g pkg');
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
const distDir = path.join(serverDir, 'dist');
|
||||
const releaseDir = path.join(serverDir, 'release');
|
||||
|
||||
// 清空并重建目录
|
||||
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(releaseDir)) fs.rmSync(releaseDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.mkdirSync(releaseDir, { recursive: true });
|
||||
|
||||
const releaseLinux = path.join(releaseDir, 'linux');
|
||||
const releaseWindows = path.join(releaseDir, 'windows');
|
||||
fs.mkdirSync(releaseLinux, { recursive: true });
|
||||
fs.mkdirSync(releaseWindows, { recursive: true });
|
||||
|
||||
// 打包 Linux 版本(适配你的 Node16)
|
||||
console.log('');
|
||||
log('🔨 打包 Linux 版本...');
|
||||
run('pkg src/index.js --targets node16-linux-x64 --output dist/jnote-api');
|
||||
|
||||
// 打包 Windows 版本(适配你的 Node16)
|
||||
console.log('');
|
||||
log('🔨 打包 Windows 版本...');
|
||||
run('pkg src/index.js --targets node16-win-x64 --output dist/jnote-api.exe');
|
||||
|
||||
// ===================== 核心优化 =====================
|
||||
// 1. 复制 Linux 可执行文件 + 生成 .sh 启动脚本
|
||||
const linuxExe = path.join(distDir, 'jnote-api');
|
||||
if (fs.existsSync(linuxExe)) {
|
||||
const targetLinuxExe = path.join(releaseLinux, 'jnote-api');
|
||||
fs.copyFileSync(linuxExe, targetLinuxExe);
|
||||
fs.chmodSync(targetLinuxExe, 0o755);
|
||||
|
||||
// 生成 Linux 原生启动脚本 start.sh
|
||||
const shScript = `#!/bin/bash
|
||||
# JNote 后端服务启动脚本
|
||||
cd "$(dirname "$0")"
|
||||
./jnote-api
|
||||
`;
|
||||
const shPath = path.join(releaseLinux, 'start.sh');
|
||||
fs.writeFileSync(shPath, shScript, 'utf8');
|
||||
fs.chmodSync(shPath, 0o755); // 赋可执行权限
|
||||
log('✓ linux/jnote-api + start.sh (可直接执行)');
|
||||
}
|
||||
|
||||
// 2. 复制 Windows 可执行文件 + 生成 .bat 启动脚本
|
||||
const winExe = path.join(distDir, 'jnote-api.exe');
|
||||
if (fs.existsSync(winExe)) {
|
||||
fs.copyFileSync(winExe, path.join(releaseWindows, 'jnote-api.exe'));
|
||||
|
||||
// 生成 Windows 原生启动脚本 start.bat
|
||||
const batScript = `@echo off
|
||||
:: JNote 后端服务启动脚本
|
||||
cd /d "%~dp0"
|
||||
jnote-api.exe
|
||||
pause
|
||||
`;
|
||||
const batPath = path.join(releaseWindows, 'start.bat');
|
||||
// ✅ 修复:Node16 兼容 UTF-8 编码
|
||||
fs.writeFileSync(batPath, batScript, 'utf8');
|
||||
log('✓ windows/jnote-api.exe + start.bat (双击运行)');
|
||||
}
|
||||
// ====================================================
|
||||
|
||||
// 复制环境配置
|
||||
const envSrc = path.join(serverDir, '.env');
|
||||
const defaultEnv = `# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://localhost:9001
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
if (fs.existsSync(envSrc)) {
|
||||
fs.copyFileSync(envSrc, path.join(releaseLinux, '.env'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseLinux, '.env.example'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseWindows, '.env'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseWindows, '.env.example'));
|
||||
} else {
|
||||
fs.writeFileSync(path.join(releaseLinux, '.env'), defaultEnv, 'utf8');
|
||||
fs.writeFileSync(path.join(releaseWindows, '.env'), defaultEnv, 'utf8');
|
||||
}
|
||||
log('✓ .env 配置文件生成完成');
|
||||
|
||||
// 创建 README
|
||||
const readme = `JNote 后端服务部署说明
|
||||
=====================
|
||||
|
||||
目录结构:
|
||||
├── linux/ # Linux 服务器部署包
|
||||
│ ├── jnote-api # Linux 可执行二进制文件
|
||||
│ ├── start.sh # Linux 原生启动脚本
|
||||
│ └── .env # 环境配置
|
||||
│
|
||||
└── windows/ # Windows 服务器部署包
|
||||
├── jnote-api.exe # Windows 可执行程序
|
||||
├── start.bat # Windows 原生启动脚本
|
||||
└── .env # 环境配置
|
||||
|
||||
部署步骤:
|
||||
|
||||
【Linux 服务器】
|
||||
1. 上传 linux/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 直接运行: ./start.sh
|
||||
|
||||
【Windows 服务器】
|
||||
1. 上传 windows/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 双击运行 start.bat 即可
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(releaseDir, 'README.txt'), readme, 'utf8');
|
||||
log('✓ README.txt 生成完成');
|
||||
|
||||
// 清理临时文件
|
||||
console.log('');
|
||||
log('🧹 清理临时文件...');
|
||||
try { fs.unlinkSync(linuxExe); } catch {}
|
||||
try { fs.unlinkSync(winExe); } catch {}
|
||||
try { fs.rmSync(distDir, { recursive: true, force: true }); } catch {}
|
||||
|
||||
console.log('');
|
||||
console.log('=========================================');
|
||||
console.log(' 打包完成!');
|
||||
console.log('=========================================');
|
||||
console.log(`分发包目录: ${releaseDir}`);
|
||||
}
|
||||
|
||||
main();
|
||||
24
server/package.json
Normal file
24
server/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "jnote-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Jnote-ui backend API server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"build": "node build.js",
|
||||
"package": "node package.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1064.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"mysql2": "^3.22.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"pkg": "5.8.1"
|
||||
}
|
||||
}
|
||||
25
server/release/README.txt
Normal file
25
server/release/README.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
JNote 后端服务部署说明
|
||||
=====================
|
||||
|
||||
目录结构:
|
||||
├── linux/ # Linux 服务器部署包
|
||||
│ ├── jnote-api # Linux 可执行二进制文件
|
||||
│ ├── start.sh # Linux 原生启动脚本
|
||||
│ └── .env # 环境配置
|
||||
│
|
||||
└── windows/ # Windows 服务器部署包
|
||||
├── jnote-api.exe # Windows 可执行程序
|
||||
├── start.bat # Windows 原生启动脚本
|
||||
└── .env # 环境配置
|
||||
|
||||
部署步骤:
|
||||
|
||||
【Linux 服务器】
|
||||
1. 上传 linux/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 直接运行: ./start.sh
|
||||
|
||||
【Windows 服务器】
|
||||
1. 上传 windows/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 双击运行 start.bat 即可
|
||||
17
server/release/linux/.env
Normal file
17
server/release/linux/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
17
server/release/linux/.env.example
Normal file
17
server/release/linux/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
BIN
server/release/linux/jnote-api
Normal file
BIN
server/release/linux/jnote-api
Normal file
Binary file not shown.
4
server/release/linux/start.sh
Normal file
4
server/release/linux/start.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# JNote 后端服务启动脚本
|
||||
cd "$(dirname "$0")"
|
||||
./jnote-api
|
||||
17
server/release/windows/.env
Normal file
17
server/release/windows/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
17
server/release/windows/.env.example
Normal file
17
server/release/windows/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
BIN
server/release/windows/jnote-api.exe
Normal file
BIN
server/release/windows/jnote-api.exe
Normal file
Binary file not shown.
5
server/release/windows/start.bat
Normal file
5
server/release/windows/start.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
:: JNote 后端服务启动脚本
|
||||
cd /d "%~dp0"
|
||||
jnote-api.exe
|
||||
pause
|
||||
166
server/src/config/cronScheduler.js
Normal file
166
server/src/config/cronScheduler.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 定时任务调度器
|
||||
* 后端运行,周期性检查并执行到期的任务
|
||||
*/
|
||||
const { getPool } = require('./database');
|
||||
const memoryStore = require('./memoryStore');
|
||||
|
||||
let schedulerInterval = null;
|
||||
let isRunning = false;
|
||||
|
||||
function parseHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (typeof headers === 'object') return headers;
|
||||
try {
|
||||
return JSON.parse(headers);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function calculateNextRun(cron) {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
const [minute, hour, day, month, weekDay] = parts;
|
||||
|
||||
// 设置分钟
|
||||
if (minute === '*') {
|
||||
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
||||
} else if (minute.includes('/')) {
|
||||
const step = parseInt(minute.split('/')[1]);
|
||||
next.setMinutes(Math.ceil(now.getMinutes() / step) * step, 0, 0);
|
||||
} else {
|
||||
next.setMinutes(parseInt(minute), 0, 0);
|
||||
if (next <= now) {
|
||||
next.setHours(next.getHours() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置小时
|
||||
if (hour !== '*') {
|
||||
if (hour.includes('/')) {
|
||||
const step = parseInt(hour.split('/')[1]);
|
||||
next.setHours(Math.ceil(now.getHours() / step) * step, 0, 0, 0);
|
||||
} else {
|
||||
next.setHours(parseInt(hour), 0, 0, 0);
|
||||
}
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
async function executeTask(task) {
|
||||
console.log(`[CronScheduler] 执行任务: ${task.name} (${task.id})`);
|
||||
|
||||
const options = {
|
||||
method: task.method || 'GET',
|
||||
headers: parseHeaders(task.headers)
|
||||
};
|
||||
|
||||
if (task.body && ['POST', 'PUT', 'PATCH'].includes(task.method)) {
|
||||
options.body = task.body;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(task.url, options);
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
console.log(`[CronScheduler] 任务 ${task.name} 执行完成: ${response.status}`);
|
||||
|
||||
// 更新数据库
|
||||
const pool = getPool();
|
||||
if (pool) {
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, task.id]
|
||||
);
|
||||
} else {
|
||||
// 更新内存存储
|
||||
const idx = memoryStore.cronTasks.findIndex(t => t.id === task.id);
|
||||
if (idx !== -1) {
|
||||
memoryStore.cronTasks[idx].lastRun = now.toLocaleString('zh-CN');
|
||||
memoryStore.cronTasks[idx].nextRun = nextRun;
|
||||
memoryStore.cronTasks[idx].runCount = (memoryStore.cronTasks[idx].runCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[CronScheduler] 任务 ${task.name} 执行失败:`, error.message);
|
||||
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
const pool = getPool();
|
||||
if (pool) {
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, task.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndRunTasks() {
|
||||
if (isRunning) return;
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const pool = getPool();
|
||||
let tasks = [];
|
||||
|
||||
if (pool) {
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE enabled = 1');
|
||||
tasks = rows;
|
||||
} else {
|
||||
tasks = memoryStore.cronTasks.filter(t => t.enabled);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.next_run && !task.nextRun) continue;
|
||||
|
||||
const nextRun = new Date(task.next_run || task.nextRun);
|
||||
if (now >= nextRun) {
|
||||
executeTask(task);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CronScheduler] 检查任务失败:', error.message);
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (schedulerInterval) {
|
||||
console.log('[CronScheduler] 已启动');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[CronScheduler] 启动定时任务调度器 (每10秒检查一次)');
|
||||
schedulerInterval = setInterval(checkAndRunTasks, 10000);
|
||||
|
||||
// 立即执行一次检查
|
||||
checkAndRunTasks();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = null;
|
||||
console.log('[CronScheduler] 已停止');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
stop
|
||||
};
|
||||
207
server/src/config/database.js
Normal file
207
server/src/config/database.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { DB_NAME, TABLE_DEFINITIONS, DEFAULT_DATA } = require('./schema');
|
||||
const memoryStore = require('./memoryStore');
|
||||
|
||||
let pool = null;
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 0
|
||||
};
|
||||
|
||||
async function initDatabase() {
|
||||
const dbHost = process.env.DB_HOST;
|
||||
const dbPassword = process.env.DB_PASSWORD;
|
||||
|
||||
if (!dbHost || !dbPassword) {
|
||||
console.log('⚠️ 数据库未配置,部分功能将使用内存存储');
|
||||
console.log(' DB_HOST:', dbHost || '未设置');
|
||||
console.log(' DB_PASSWORD:', dbPassword ? '已设置' : '未设置');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: 连接数据库服务器(不指定数据库)
|
||||
const initConfig = { ...config };
|
||||
delete initConfig.database;
|
||||
const initConnection = await mysql.createConnection(initConfig);
|
||||
console.log('🔌 已连接数据库服务器');
|
||||
|
||||
// Step 2: 创建数据库(如果不存在)
|
||||
await initConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
);
|
||||
console.log(`📦 数据库 ${DB_NAME} ${await databaseExists(initConnection) ? '已存在' : '已创建'}`);
|
||||
|
||||
// Step 3: 选择数据库
|
||||
await initConnection.query(`USE \`${DB_NAME}\``);
|
||||
|
||||
// Step 4: 创建所有表
|
||||
for (const table of TABLE_DEFINITIONS) {
|
||||
await initConnection.query(table.sql);
|
||||
console.log(`📋 ${table.name} 表 ${await tableExists(initConnection, table.name) ? '已存在' : '已创建'}`);
|
||||
}
|
||||
|
||||
// Step 5: 初始化默认数据
|
||||
await ensureDefaultData(initConnection);
|
||||
|
||||
await initConnection.end();
|
||||
|
||||
// Step 6: 创建连接池
|
||||
const poolConfig = { ...config, database: DB_NAME };
|
||||
pool = mysql.createPool(poolConfig);
|
||||
|
||||
// 测试连接池
|
||||
const connection = await pool.getConnection();
|
||||
console.log('✅ 数据库连接池创建成功');
|
||||
connection.release();
|
||||
|
||||
// 预热缓存(不阻塞启动)
|
||||
warmUpCache().catch(err => console.error('预热失败:', err.message));
|
||||
|
||||
return pool;
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error.message);
|
||||
console.log('⚠️ 将使用内存存储作为后备');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查数据库是否存在
|
||||
async function databaseExists(connection) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
|
||||
[DB_NAME]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// 检查表是否存在
|
||||
async function tableExists(connection, tableName) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
|
||||
[DB_NAME, tableName]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// 确保默认数据存在
|
||||
async function ensureDefaultData(connection) {
|
||||
try {
|
||||
// 检查 settings 是否存在
|
||||
const [settingsRows] = await connection.query('SELECT id FROM settings WHERE id = 1');
|
||||
if (settingsRows.length === 0) {
|
||||
await connection.query(
|
||||
'INSERT INTO settings (id, bg_type, bg_color, bg_opacity, language) VALUES (1, ?, ?, ?, ?)',
|
||||
[DEFAULT_DATA.settings.bg_type, DEFAULT_DATA.settings.bg_color, DEFAULT_DATA.settings.bg_opacity, DEFAULT_DATA.settings.language]
|
||||
);
|
||||
console.log(' settings 默认数据已创建');
|
||||
}
|
||||
|
||||
// 检查 about 是否存在
|
||||
const [aboutRows] = await connection.query('SELECT id FROM about WHERE id = 1');
|
||||
if (aboutRows.length === 0) {
|
||||
await connection.query(
|
||||
'INSERT INTO about (id, title, intro, blog, tech, features, contact, filing_number) VALUES (1, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
DEFAULT_DATA.about.title,
|
||||
DEFAULT_DATA.about.intro,
|
||||
DEFAULT_DATA.about.blog,
|
||||
DEFAULT_DATA.about.tech,
|
||||
DEFAULT_DATA.about.features,
|
||||
DEFAULT_DATA.about.contact,
|
||||
DEFAULT_DATA.about.filing_number
|
||||
]
|
||||
);
|
||||
console.log(' about 默认数据已创建');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ER_DUP_ENTRY') {
|
||||
console.error(' 默认数据初始化失败:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据预热 - 启动时加载常用数据到内存
|
||||
async function warmUpCache() {
|
||||
if (!pool) return;
|
||||
|
||||
try {
|
||||
const [posts] = await pool.execute('SELECT * FROM posts ORDER BY date DESC');
|
||||
const [settings] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
const [about] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
|
||||
memoryStore.posts = posts;
|
||||
if (settings.length > 0) {
|
||||
memoryStore.settings = formatSettings(settings[0]);
|
||||
}
|
||||
if (about.length > 0) {
|
||||
memoryStore.about = formatAbout(about[0]);
|
||||
}
|
||||
|
||||
console.log('✅ 数据预热完成');
|
||||
} catch (error) {
|
||||
console.error('数据预热失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSettings(row) {
|
||||
if (!row) return memoryStore.settings;
|
||||
|
||||
return {
|
||||
bgType: row.bg_type,
|
||||
bgColor: row.bg_color,
|
||||
bgImage: row.bg_image,
|
||||
bgOpacity: row.bg_opacity,
|
||||
language: row.language,
|
||||
favicon: row.favicon,
|
||||
uploadedImages: parseJSON(row.uploaded_images, []),
|
||||
uploadedIcons: parseJSON(row.uploaded_icons, [])
|
||||
};
|
||||
}
|
||||
|
||||
function formatAbout(row) {
|
||||
if (!row) return memoryStore.about;
|
||||
const about = { ...row };
|
||||
if (about.tech && typeof about.tech === 'string') {
|
||||
about.techList = about.tech.split(',').map(t => t.trim());
|
||||
}
|
||||
if (about.features && typeof about.features === 'string') {
|
||||
try {
|
||||
about.featuresList = JSON.parse(about.features);
|
||||
} catch {
|
||||
about.featuresList = [];
|
||||
}
|
||||
}
|
||||
if (about.contact && typeof about.contact === 'string') {
|
||||
try {
|
||||
about.contactObj = JSON.parse(about.contact);
|
||||
} catch {
|
||||
about.contactObj = {};
|
||||
}
|
||||
}
|
||||
return about;
|
||||
}
|
||||
|
||||
function parseJSON(str, defaultValue) {
|
||||
if (!str) return defaultValue;
|
||||
if (typeof str === 'object') return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
module.exports = { initDatabase, getPool };
|
||||
110
server/src/config/envManager.js
Normal file
110
server/src/config/envManager.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* .env 文件管理器
|
||||
* 读取和写入环境配置
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ENV_PATH = path.join(__dirname, '../../.env');
|
||||
|
||||
// 配置字段定义
|
||||
const CONFIG_FIELDS = {
|
||||
db: ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'],
|
||||
rustfs: ['RUSTFS_ENDPOINT', 'RUSTFS_REGION', 'RUSTFS_BUCKET', 'RUSTFS_ACCESS_KEY', 'RUSTFS_SECRET_KEY'],
|
||||
server: ['PORT']
|
||||
};
|
||||
|
||||
// 读取 .env 文件
|
||||
function readEnv() {
|
||||
try {
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
return {};
|
||||
}
|
||||
const content = fs.readFileSync(ENV_PATH, 'utf-8');
|
||||
const result = {};
|
||||
|
||||
content.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
|
||||
const idx = line.indexOf('=');
|
||||
if (idx > 0) {
|
||||
const key = line.substring(0, idx).trim();
|
||||
const value = line.substring(idx + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('读取 .env 失败:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 .env 文件
|
||||
function writeEnv(config) {
|
||||
try {
|
||||
const lines = [];
|
||||
|
||||
// 数据库配置
|
||||
lines.push('# 数据库配置');
|
||||
lines.push(`DB_HOST=${config.DB_HOST || 'localhost'}`);
|
||||
lines.push(`DB_PORT=${config.DB_PORT || 3306}`);
|
||||
lines.push(`DB_USER=${config.DB_USER || 'root'}`);
|
||||
lines.push(`DB_PASSWORD=${config.DB_PASSWORD || ''}`);
|
||||
lines.push(`DB_NAME=${config.DB_NAME || 'jnote'}`);
|
||||
lines.push('');
|
||||
|
||||
// RUSTFS 配置
|
||||
lines.push('# RUSTFS 对象存储配置');
|
||||
lines.push(`RUSTFS_ENDPOINT=${config.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001'}`);
|
||||
lines.push(`RUSTFS_REGION=${config.RUSTFS_REGION || 'us-east-1'}`);
|
||||
lines.push(`RUSTFS_BUCKET=${config.RUSTFS_BUCKET || 'setting'}`);
|
||||
lines.push(`RUSTFS_ACCESS_KEY=${config.RUSTFS_ACCESS_KEY || 'rustfsadmin'}`);
|
||||
lines.push(`RUSTFS_SECRET_KEY=${config.RUSTFS_SECRET_KEY || 'rustfsadmin'}`);
|
||||
lines.push('');
|
||||
|
||||
// 服务器配置
|
||||
lines.push('# 服务器配置');
|
||||
lines.push(`PORT=${config.PORT || 3000}`);
|
||||
|
||||
fs.writeFileSync(ENV_PATH, lines.join('\n'), 'utf-8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('写入 .env 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取安全配置(密码隐藏)
|
||||
function getSafeConfig() {
|
||||
const config = readEnv();
|
||||
|
||||
return {
|
||||
db: {
|
||||
host: config.DB_HOST || 'localhost',
|
||||
port: config.DB_PORT || '3306',
|
||||
user: config.DB_USER || 'root',
|
||||
password: config.DB_PASSWORD ? '******' : '',
|
||||
name: config.DB_NAME || 'jnote'
|
||||
},
|
||||
rustfs: {
|
||||
endpoint: config.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001',
|
||||
region: config.RUSTFS_REGION || 'us-east-1',
|
||||
bucket: config.RUSTFS_BUCKET || 'setting',
|
||||
accessKey: config.RUSTFS_ACCESS_KEY || 'rustfsadmin',
|
||||
secretKey: config.RUSTFS_SECRET_KEY ? '******' : ''
|
||||
},
|
||||
server: {
|
||||
port: config.PORT || '3000'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readEnv,
|
||||
writeEnv,
|
||||
getSafeConfig,
|
||||
CONFIG_FIELDS
|
||||
};
|
||||
50
server/src/config/memoryStore.js
Normal file
50
server/src/config/memoryStore.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// 内存存储后备方案(数据库未配置时使用)
|
||||
const memoryStore = {
|
||||
posts: [
|
||||
{ id: 1, title: '欢迎来到我的编程学习记录', date: '2026-06-01', excerpt: '这是我的第一篇文章,欢迎大家来访!', content: '这是我的第一篇文章,欢迎大家来访!在这里我会分享一些技术心得和生活感悟。希望大家喜欢这里的内容,有任何问题欢迎留言交流。' },
|
||||
{ id: 2, title: 'Vue 3 组合式 API 入门', date: '2026-06-02', excerpt: '探索 Vue 3 的组合式 API,感受更好的代码组织方式。', content: 'Vue 3 引入了组合式 API,这是一种全新的代码组织方式。通过 setup 函数和响应式 API,我们可以更灵活地组织组件逻辑,提高代码的可复用性和可维护性。\n\n主要特点:\n1. 更好的逻辑复用\n2. 更灵活的代码组织\n3. 更好的 TypeScript 支持' },
|
||||
{ id: 3, title: '静态网站生成器选型', date: '2026-06-03', excerpt: '对比常见的静态网站生成器,帮助你选择合适的工具。', content: '选择一个合适的静态网站生成器需要考虑多个因素:\n\n1. Hexo - 简单易用,主题丰富\n2. Jekyll - GitHub Pages 原生支持,社区活跃\n3. Hugo - 构建速度极快\n4. VuePress / VitePress - 基于 Vue,适合技术文档\n\n本博客使用 Vue 3 + Vite 构建,兼具现代特性和开发体验。' },
|
||||
{ id: 4, title: '优雅地使用 Pinia 管理状态', date: '2026-06-04', excerpt: 'Pinia 是 Vue 3 推荐的状态管理方案,本篇介绍其基本用法。', content: 'Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 更加轻量和直观。\n\n核心概念:\n- Store - 存储状态的地方\n- Getters - 类似 computed 的状态计算\n- Actions - 处理异步逻辑或修改状态' },
|
||||
{ id: 5, title: 'CSS Grid 布局实战技巧', date: '2026-06-05', excerpt: '深入探索 CSS Grid,让页面布局变得轻而易举。', content: 'CSS Grid 是现代 CSS 布局的重要组成部分,比 Flexbox 更适合二维布局场景。\n\n基础用法:\n.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }\n\n常用场景:\n1. 相册网格 - 自动填充列数\n2. 圣杯布局 - 经典三栏布局\n3. 响应式卡片 - 自动换行适配' },
|
||||
{ id: 6, title: '用 Git 工作流提升团队协作效率', date: '2026-06-06', excerpt: 'GitFlow vs trunk-based development,选对工作流事半功倍。', content: '良好的 Git 工作流能让团队协作更加顺畅。常见的工作流模型:\n\n1. GitFlow - 分支模型清晰,适合发布周期稳定的项目\n2. trunk-based development - 主干开发,快速迭代\n3. fork workflow - 开源项目首选,隔离性强\n\n小团队推荐使用简化 GitFlow:main + develop + feature 分支。' },
|
||||
{ id: 7, title: 'JavaScript 事件循环机制详解', date: '2026-06-07', excerpt: '理解 Event Loop、宏任务与微任务,告别异步编程困惑。', content: '事件循环是 JavaScript 异步编程的核心机制。\n\n执行顺序:\n1. 同步代码优先执行\n2. 微任务(Promise、MutationObserver)\n3. 宏任务(setTimeout、setInterval、setImmediate)\n\n示例:\nconsole.log(\'1\');\nsetTimeout(() => console.log(\'2\'), 0);\nPromise.resolve().then(() => console.log(\'3\'));\nconsole.log(\'4\');\n\n输出顺序:1, 4, 3, 2' },
|
||||
{ id: 8, title: '我的开发环境配置分享', date: '2026-06-08', excerpt: 'VS Code + Zsh + Tmux,打造高效开发终端。', content: '工欲善其事,必先利其器。分享我的开发环境配置:\n\n编辑器:\n- VS Code + Vim 插件\n- Tokyo Night 主题\n- Fira Code 字体 + 连字\n\n终端:\n- iTerm2 (macOS) / Windows Terminal\n- Oh My Zsh\n- Tmux 会话管理\n\n版本管理:\n- Git + delta 美化 diff\n- lazygit 终端 UI' },
|
||||
{ id: 9, title: '从零搭建一个 CLI 工具', date: '2026-06-09', excerpt: '使用 Node.js 和 commander.js 快速构建命令行工具。', content: '命令行工具是提升开发效率的利器。使用 Node.js 可以快速构建:\n\n初始化项目:\nnpm init -y\nnpm install commander inquirer\n\n核心代码:\nconst { program } = require(\'commander\');\nprogram.version(\'1.0.0\');\nprogram.option(\'-n, --name <name>\', \'项目名称\');\nprogram.parse(process.argv);\n\n发布到 npm:\nnpm publish --access public' },
|
||||
{ id: 10, title: '为什么我喜欢深夜编程', date: '2026-06-10', excerpt: '安静、专注、不被打扰,深夜是程序员的黄金时段。', content: '深夜编程的独特魅力:\n\n1. 绝对安静 - 无人打扰,思路连贯\n2. 效率翻倍 - 深度工作状态\n3. 问题解决 - 复杂 bug 往往在这时候被攻破\n\n当然,也要适度。注意休息,保护眼睛,第二天才能持续输出。\n\n推荐配合:黑咖啡 + 轻音乐 + 舒适的降噪耳机。' }
|
||||
],
|
||||
|
||||
about: {
|
||||
id: 1,
|
||||
title: '关于我',
|
||||
intro: '你好!我是一名热爱技术开发的开发者。',
|
||||
blog: '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
tech: 'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
features: JSON.stringify([
|
||||
'定时任务系统 - 支持 Cron表达式的自动化任务管理',
|
||||
'自定义背景 - 支持纯色和图片背景,可调节透明度',
|
||||
'多语言支持 - 中英文切换',
|
||||
'自定义图标 - 上传本地图片作为网站图标'
|
||||
]),
|
||||
contact: JSON.stringify({
|
||||
email: 'example@email.com',
|
||||
github: 'github.com/yourusername'
|
||||
}),
|
||||
filing_number: '桂ICP备2022004108号-1'
|
||||
},
|
||||
|
||||
settings: {
|
||||
id: 1,
|
||||
bg_type: 'color',
|
||||
bg_color: '#f5f5f5',
|
||||
bg_image: '',
|
||||
bg_opacity: 1.00,
|
||||
language: 'zh',
|
||||
favicon: '',
|
||||
uploaded_images: JSON.stringify([]),
|
||||
uploaded_icons: JSON.stringify([])
|
||||
},
|
||||
|
||||
cronTasks: []
|
||||
};
|
||||
|
||||
module.exports = memoryStore;
|
||||
235
server/src/config/rustfs.js
Normal file
235
server/src/config/rustfs.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* RUSTFS 对象存储配置
|
||||
* 直接使用 HTTP API 避免 AWS SDK 兼容性问题
|
||||
*/
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const config = {
|
||||
endpoint: process.env.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001',
|
||||
bucket: process.env.RUSTFS_BUCKET || 'setting',
|
||||
accessKey: process.env.RUSTFS_ACCESS_KEY || 'rustfsadmin',
|
||||
secretKey: process.env.RUSTFS_SECRET_KEY || 'rustfsadmin'
|
||||
};
|
||||
|
||||
// 生成 AWS Signature V4
|
||||
function createSignature(method, path, headers, query = '') {
|
||||
const date = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
const dateShort = date.substring(0, 8);
|
||||
|
||||
const payloadHash = crypto.createHash('sha256').update('').digest('hex');
|
||||
const canonicalHeaders = Object.entries(headers)
|
||||
.map(([k, v]) => `${k.toLowerCase()}:${v}`)
|
||||
.join('\n');
|
||||
const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).join(';');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
canonicalHeaders,
|
||||
'',
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join('\n');
|
||||
|
||||
const credentialScope = `${dateShort}/us-east-1/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
date,
|
||||
credentialScope,
|
||||
crypto.createHash('sha256').update(canonicalRequest).digest('hex')
|
||||
].join('\n');
|
||||
|
||||
const kDate = crypto.createHmac('sha256', `AWS4${config.secretKey}`).update(dateShort).digest();
|
||||
const kRegion = crypto.createHmac('sha256', kDate).update('us-east-1').digest();
|
||||
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
||||
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
||||
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
||||
|
||||
return {
|
||||
authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
'x-amz-date': date,
|
||||
'x-amz-content-sha256': payloadHash
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(method, path, body = null, contentType = 'application/octet-stream') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(config.endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const query = '';
|
||||
const headers = {
|
||||
'host': url.host,
|
||||
'content-type': contentType,
|
||||
'x-amz-date': new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8) + 'T000000Z'
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers['content-length'] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
// 简化认证:使用 Access Key 作为 Authorization header
|
||||
headers['Authorization'] = `AWS ${config.accessKey}:${crypto.createHash('sha256').update(config.secretKey).digest('hex')}`;
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}${path}`,
|
||||
method: method,
|
||||
headers: headers
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 RUSTFS
|
||||
*/
|
||||
async function uploadFile(fileData, key, contentType = 'image/jpeg') {
|
||||
// 解析 endpoint
|
||||
const endpoint = config.endpoint;
|
||||
const url = new URL(endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const date = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8);
|
||||
const datetime = date + 'T000000Z';
|
||||
|
||||
// 创建签名字符串
|
||||
const credential = `${config.accessKey}/${date}/us-east-1/s3/aws4_request`;
|
||||
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date';
|
||||
const contentHash = crypto.createHash('sha256').update(fileData).digest('hex');
|
||||
|
||||
const canonicalHeaders = [
|
||||
`content-type:${contentType}`,
|
||||
`host:${url.host}`,
|
||||
`x-amz-content-sha256:${contentHash}`,
|
||||
`x-amz-date:${datetime}`
|
||||
].join('\n');
|
||||
|
||||
const canonicalRequest = [
|
||||
'PUT',
|
||||
`/${config.bucket}/${key}`,
|
||||
'',
|
||||
canonicalHeaders,
|
||||
'',
|
||||
signedHeaders,
|
||||
contentHash
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
datetime,
|
||||
`${date}/us-east-1/s3/aws4_request`,
|
||||
crypto.createHash('sha256').update(canonicalRequest).digest('hex')
|
||||
].join('\n');
|
||||
|
||||
// 计算签名
|
||||
const k1 = crypto.createHmac('sha256', 'AWS4' + config.secretKey).update(date).digest();
|
||||
const k2 = crypto.createHmac('sha256', k1).update('us-east-1').digest();
|
||||
const k3 = crypto.createHmac('sha256', k2).update('s3').digest();
|
||||
const k4 = crypto.createHmac('sha256', k3).update('aws4_request').digest();
|
||||
const signature = crypto.createHmac('sha256', k4).update(stringToSign).digest('hex');
|
||||
|
||||
const authorization = `AWS4-HMAC-SHA256 Credential=${credential}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}/${key}`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Host': url.host,
|
||||
'X-Amz-Content-Sha256': contentHash,
|
||||
'X-Amz-Date': datetime,
|
||||
'Authorization': authorization,
|
||||
'Content-Length': Buffer.byteLength(fileData)
|
||||
}
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(`${endpoint}/${config.bucket}/${key}`);
|
||||
} else {
|
||||
reject(new Error(`Upload failed: HTTP ${res.statusCode} - ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(fileData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 RUSTFS 中的文件
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
const endpoint = config.endpoint;
|
||||
const url = new URL(endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const datetime = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8) + 'T000000Z';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}/${key}`,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Host': url.host,
|
||||
'X-Amz-Date': datetime,
|
||||
'Authorization': `AWS ${config.accessKey}:${crypto.createHash('sha256').update(config.secretKey).digest('hex')}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => reject(new Error(`Delete failed: HTTP ${res.statusCode}`)));
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
config
|
||||
};
|
||||
108
server/src/config/schema.js
Normal file
108
server/src/config/schema.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 数据库表结构定义
|
||||
* 包含所有表的 CREATE TABLE 语句
|
||||
*/
|
||||
|
||||
const DB_NAME = process.env.DB_NAME || 'jnote';
|
||||
|
||||
// posts 表 - content 改为 MEDIUMTEXT 支持更大内容
|
||||
const CREATE_POSTS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
excerpt TEXT,
|
||||
content MEDIUMTEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_posts_date (date DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// about 表 - features 和 contact 改为 JSON 类型
|
||||
const CREATE_ABOUT_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS about (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
title VARCHAR(255) DEFAULT '关于我',
|
||||
intro TEXT,
|
||||
blog TEXT,
|
||||
tech TEXT,
|
||||
features JSON,
|
||||
contact JSON,
|
||||
filing_number VARCHAR(100),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// settings 表 - uploaded_images/icons 改为 JSON 类型,bg_image/favicon 改为更节省空间的 TEXT
|
||||
const CREATE_SETTINGS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
bg_type VARCHAR(20) DEFAULT 'color',
|
||||
bg_color VARCHAR(20) DEFAULT '#f5f5f5',
|
||||
bg_image TEXT,
|
||||
bg_opacity DECIMAL(3,2) DEFAULT 1.00,
|
||||
language VARCHAR(10) DEFAULT 'zh',
|
||||
favicon TEXT,
|
||||
uploaded_images JSON,
|
||||
uploaded_icons JSON,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// cron_tasks 表
|
||||
const CREATE_CRON_TASKS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS cron_tasks (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cron VARCHAR(50) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
method VARCHAR(20) DEFAULT 'GET',
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
last_run DATETIME,
|
||||
next_run DATETIME,
|
||||
run_count INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// 所有表创建的执行顺序(按依赖关系)
|
||||
const TABLE_DEFINITIONS = [
|
||||
{ name: 'posts', sql: CREATE_POSTS_TABLE },
|
||||
{ name: 'about', sql: CREATE_ABOUT_TABLE },
|
||||
{ name: 'settings', sql: CREATE_SETTINGS_TABLE },
|
||||
{ name: 'cron_tasks', sql: CREATE_CRON_TASKS_TABLE }
|
||||
];
|
||||
|
||||
// 默认数据
|
||||
const DEFAULT_DATA = {
|
||||
about: {
|
||||
title: '关于我',
|
||||
intro: '你好!我是一名热爱技术开发的开发者。',
|
||||
blog: '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
tech: 'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
features: JSON.stringify([
|
||||
'定时任务系统 - 支持 Cron表达式的自动化任务管理',
|
||||
'自定义背景 - 支持纯色和图片背景,可调节透明度',
|
||||
'多语言支持 - 中英文切换',
|
||||
'自定义图标 - 上传本地图片作为网站图标'
|
||||
]),
|
||||
contact: JSON.stringify({ email: 'example@email.com', github: 'github.com/yourusername' }),
|
||||
filing_number: '桂ICP备2022004108号-1'
|
||||
},
|
||||
settings: {
|
||||
bg_type: 'color',
|
||||
bg_color: '#f5f5f5',
|
||||
bg_opacity: 1.00,
|
||||
language: 'zh'
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
TABLE_DEFINITIONS,
|
||||
DEFAULT_DATA
|
||||
};
|
||||
52
server/src/index.js
Normal file
52
server/src/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { initDatabase } = require('./config/database');
|
||||
const cronScheduler = require('./config/cronScheduler');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// 路由
|
||||
const postsRouter = require('./routes/posts');
|
||||
const aboutRouter = require('./routes/about');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const cronRouter = require('./routes/cron');
|
||||
const configRouter = require('./routes/config');
|
||||
|
||||
app.use('/api/posts', postsRouter);
|
||||
app.use('/api/about', aboutRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
app.use('/api/cron-tasks', cronRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
|
||||
// 启动定时任务调度器
|
||||
cronScheduler.start();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
|
||||
console.log(`📖 API 文档: http://localhost:${PORT}/api/health`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
96
server/src/routes/about.js
Normal file
96
server/src/routes/about.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 获取关于内容 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据
|
||||
if (memoryStore.about && memoryStore.about.id === 1) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
if (rows.length === 0) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
const about = formatAbout(rows[0]);
|
||||
memoryStore.about = about;
|
||||
res.json(about);
|
||||
} catch (error) {
|
||||
console.error('获取关于内容失败:', error);
|
||||
res.json(memoryStore.about);
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化 about 数据 - MySQL JSON 类型直接返回对象
|
||||
function formatAbout(row) {
|
||||
if (!row) return memoryStore.about;
|
||||
const about = { ...row };
|
||||
if (about.tech && typeof about.tech === 'string') {
|
||||
about.techList = about.tech.split(',').map(t => t.trim());
|
||||
}
|
||||
// MySQL JSON 类型直接返回 JS 对象,不需要 JSON.parse
|
||||
if (about.features && typeof about.features === 'string') {
|
||||
try {
|
||||
about.featuresList = JSON.parse(about.features);
|
||||
} catch {
|
||||
about.featuresList = Array.isArray(about.features) ? about.features : [];
|
||||
}
|
||||
} else if (Array.isArray(about.features)) {
|
||||
about.featuresList = about.features;
|
||||
}
|
||||
if (about.contact && typeof about.contact === 'string') {
|
||||
try {
|
||||
about.contactObj = JSON.parse(about.contact);
|
||||
} catch {
|
||||
about.contactObj = typeof about.contact === 'object' ? about.contact : {};
|
||||
}
|
||||
} else if (typeof about.contact === 'object') {
|
||||
about.contactObj = about.contact;
|
||||
}
|
||||
return about;
|
||||
}
|
||||
|
||||
// 更新关于内容
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { title, intro, blog, tech, features, contact, filing_number } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
Object.assign(memoryStore.about, { title, intro, blog, tech, features, contact, filing_number });
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE about SET title = ?, intro = ?, blog = ?, tech = ?, features = ?, contact = ?, filing_number = ? WHERE id = 1`,
|
||||
[
|
||||
title || '关于我',
|
||||
intro || '',
|
||||
tech || '',
|
||||
typeof features === 'object' ? JSON.stringify(features) : features || '[]',
|
||||
typeof contact === 'object' ? JSON.stringify(contact) : contact || '{}',
|
||||
filing_number || ''
|
||||
]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
const updated = formatAbout(rows[0]);
|
||||
memoryStore.about = updated;
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('更新关于内容失败:', error);
|
||||
res.status(500).json({ error: '更新关于内容失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
server/src/routes/config.js
Normal file
73
server/src/routes/config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const envManager = require('../config/envManager');
|
||||
|
||||
// 获取当前配置(密码隐藏)
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const config = envManager.getSafeConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
res.status(500).json({ error: '获取配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新配置
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const { db, rustfs, server } = req.body;
|
||||
|
||||
const config = {
|
||||
// 数据库配置
|
||||
DB_HOST: db?.host || 'localhost',
|
||||
DB_PORT: db?.port || '3306',
|
||||
DB_USER: db?.user || 'root',
|
||||
DB_PASSWORD: db?.password || '',
|
||||
DB_NAME: db?.name || 'jnote',
|
||||
// RUSTFS 配置
|
||||
RUSTFS_ENDPOINT: rustfs?.endpoint || 'http://43.156.91.115:9001',
|
||||
RUSTFS_REGION: rustfs?.region || 'us-east-1',
|
||||
RUSTFS_BUCKET: rustfs?.bucket || 'setting',
|
||||
RUSTFS_ACCESS_KEY: rustfs?.accessKey || 'rustfsadmin',
|
||||
RUSTFS_SECRET_KEY: rustfs?.secretKey || 'rustfsadmin',
|
||||
// 服务器配置
|
||||
PORT: server?.port || '3000'
|
||||
};
|
||||
|
||||
// 如果密码字段为 ******,说明没改,从原文件读取
|
||||
if (db?.password === '******') {
|
||||
const oldConfig = envManager.readEnv();
|
||||
config.DB_PASSWORD = oldConfig.DB_PASSWORD || '';
|
||||
}
|
||||
if (rustfs?.secretKey === '******') {
|
||||
const oldConfig = envManager.readEnv();
|
||||
config.RUSTFS_SECRET_KEY = oldConfig.RUSTFS_SECRET_KEY || '';
|
||||
}
|
||||
|
||||
const success = envManager.writeEnv(config);
|
||||
if (!success) {
|
||||
return res.status(500).json({ error: '保存配置失败' });
|
||||
}
|
||||
|
||||
res.json(envManager.getSafeConfig());
|
||||
} catch (error) {
|
||||
console.error('更新配置失败:', error);
|
||||
res.status(500).json({ error: '更新配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启服务
|
||||
router.post('/restart', (req, res) => {
|
||||
console.log('收到重启信号,准备关闭服务...');
|
||||
|
||||
// 优雅退出,让 PM2 或其他进程管理器自动重启
|
||||
res.json({ message: '服务即将重启', delay: 1000 });
|
||||
|
||||
// 延迟一点再退出,让响应先发出去
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
249
server/src/routes/cron.js
Normal file
249
server/src/routes/cron.js
Normal file
@@ -0,0 +1,249 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 获取所有定时任务
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.cronTasks);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks ORDER BY created_at DESC');
|
||||
res.json(rows.map(formatCronTask));
|
||||
} catch (error) {
|
||||
console.error('获取定时任务失败:', error);
|
||||
res.json(memoryStore.cronTasks);
|
||||
}
|
||||
});
|
||||
|
||||
// 创建定时任务
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, cron, url, method, headers, body, enabled } = req.body;
|
||||
|
||||
if (!name || !cron || !url) {
|
||||
return res.status(400).json({ error: '任务名称、Cron表达式和URL是必填项' });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const id = Date.now().toString();
|
||||
const nextRun = calculateNextRun(cron);
|
||||
|
||||
if (!pool) {
|
||||
const newTask = {
|
||||
id,
|
||||
name,
|
||||
cron,
|
||||
url,
|
||||
method: method || 'GET',
|
||||
headers: headers || '',
|
||||
body: body || '',
|
||||
enabled: enabled !== false,
|
||||
lastRun: null,
|
||||
nextRun,
|
||||
runCount: 0
|
||||
};
|
||||
memoryStore.cronTasks.push(newTask);
|
||||
return res.status(201).json(newTask);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO cron_tasks (id, name, cron, url, method, headers, body, enabled, next_run, run_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
|
||||
[id, name, cron, url, method || 'GET', headers || '', body || '', enabled !== false ? 1 : 0, nextRun]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [id]);
|
||||
res.status(201).json(formatCronTask(rows[0]));
|
||||
} catch (error) {
|
||||
console.error('创建定时任务失败:', error);
|
||||
res.status(500).json({ error: '创建定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新定时任务
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, cron, url, method, headers, body, enabled } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.cronTasks.findIndex(t => t.id === req.params.id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
const nextRun = calculateNextRun(cron);
|
||||
memoryStore.cronTasks[index] = {
|
||||
...memoryStore.cronTasks[index],
|
||||
name, cron, url, method, headers, body, enabled, nextRun
|
||||
};
|
||||
return res.json(memoryStore.cronTasks[index]);
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRun(cron);
|
||||
await pool.execute(
|
||||
`UPDATE cron_tasks SET name = ?, cron = ?, url = ?, method = ?, headers = ?, body = ?, enabled = ?, next_run = ? WHERE id = ?`,
|
||||
[name, cron, url, method || 'GET', headers || '', body || '', enabled ? 1 : 0, nextRun, req.params.id]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
res.json(formatCronTask(rows[0]));
|
||||
} catch (error) {
|
||||
console.error('更新定时任务失败:', error);
|
||||
res.status(500).json({ error: '更新定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除定时任务
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.cronTasks.findIndex(t => t.id === req.params.id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
memoryStore.cronTasks.splice(index, 1);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
const [result] = await pool.execute('DELETE FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('删除定时任务失败:', error);
|
||||
res.status(500).json({ error: '删除定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 立即执行任务
|
||||
router.post('/:id/run', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
let task;
|
||||
if (!pool) {
|
||||
task = memoryStore.cronTasks.find(t => t.id === req.params.id);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
} else {
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
task = formatCronTask(rows[0]);
|
||||
}
|
||||
|
||||
// 执行 HTTP 请求
|
||||
const options = {
|
||||
method: task.method || 'GET',
|
||||
headers: parseHeaders(task.headers)
|
||||
};
|
||||
|
||||
if (task.body && ['POST', 'PUT', 'PATCH'].includes(task.method)) {
|
||||
options.body = task.body;
|
||||
}
|
||||
|
||||
const response = await fetch(task.url, options);
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
if (!pool) {
|
||||
task.lastRun = now.toLocaleString('zh-CN');
|
||||
task.runCount = (task.runCount || 0) + 1;
|
||||
task.nextRun = nextRun;
|
||||
return res.json({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
lastRun: task.lastRun,
|
||||
nextRun: task.nextRun,
|
||||
runCount: task.runCount
|
||||
});
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, req.params.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
lastRun: now.toLocaleString('zh-CN'),
|
||||
nextRun,
|
||||
runCount: task.runCount + 1
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('执行任务失败:', error);
|
||||
res.status(500).json({ error: '执行任务失败: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function formatCronTask(row) {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
cron: row.cron,
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
headers: row.headers,
|
||||
body: row.body,
|
||||
enabled: Boolean(row.enabled),
|
||||
lastRun: row.last_run ? row.last_run.toLocaleString('zh-CN') : null,
|
||||
nextRun: row.next_run ? row.next_run.toLocaleString('zh-CN') : null,
|
||||
runCount: row.run_count || 0
|
||||
};
|
||||
}
|
||||
|
||||
function parseHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (typeof headers === 'object') return headers;
|
||||
try {
|
||||
return JSON.parse(headers);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function calculateNextRun(cron) {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
const minute = parts[0];
|
||||
const hour = parts[1];
|
||||
|
||||
if (minute === '*') {
|
||||
next.setMinutes(now.getMinutes() + 1);
|
||||
} else if (minute.includes('/')) {
|
||||
const step = parseInt(minute.split('/')[1]);
|
||||
next.setMinutes(Math.ceil(now.getMinutes() / step) * step);
|
||||
} else {
|
||||
next.setMinutes(parseInt(minute));
|
||||
if (next <= now) next.setHours(next.getHours() + 1);
|
||||
}
|
||||
|
||||
if (hour !== '*') {
|
||||
next.setHours(parseInt(hour), 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
188
server/src/routes/posts.js
Normal file
188
server/src/routes/posts.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 格式化日期为 YYYY-MM-DD HH:mm:ss
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return date;
|
||||
const pad = n => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// 获取所有文章 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.posts);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据(已预热)
|
||||
if (memoryStore.posts && memoryStore.posts.length > 0) {
|
||||
return res.json(memoryStore.posts.map(p => ({ ...p, date: formatDate(p.date) })));
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts ORDER BY date DESC');
|
||||
const formatted = rows.map(r => ({ ...r, date: formatDate(r.date) }));
|
||||
// 更新缓存
|
||||
memoryStore.posts = rows;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
res.json(memoryStore.posts || []);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单篇文章
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const post = memoryStore.posts.find(p => p.id === id);
|
||||
return post ? res.json({ ...post, date: formatDate(post.date) }) : res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
|
||||
// 先在缓存中查找
|
||||
if (memoryStore.posts && memoryStore.posts.length > 0) {
|
||||
const cached = memoryStore.posts.find(p => p.id === id);
|
||||
if (cached) {
|
||||
return res.json({ ...cached, date: formatDate(cached.date) });
|
||||
}
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
res.json({ ...rows[0], date: formatDate(rows[0].date) });
|
||||
} catch (error) {
|
||||
console.error('获取文章失败:', error);
|
||||
const post = memoryStore.posts.find(p => p.id === Number(req.params.id));
|
||||
post ? res.json({ ...post, date: formatDate(post.date) }) : res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建文章
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { title, date, excerpt, content } = req.body;
|
||||
|
||||
if (!title || !date) {
|
||||
return res.status(400).json({ error: '标题和日期是必填项' });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const newPost = {
|
||||
id: memoryStore.posts.length + 1,
|
||||
title,
|
||||
date,
|
||||
excerpt: excerpt || '',
|
||||
content: content || ''
|
||||
};
|
||||
memoryStore.posts.unshift(newPost);
|
||||
return res.status(201).json({ ...newPost, date: formatDate(newPost.date) });
|
||||
}
|
||||
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO posts (title, date, excerpt, content) VALUES (?, ?, ?, ?)',
|
||||
[title, date, excerpt || '', content || '']
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [result.insertId]);
|
||||
const newPost = rows[0];
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
memoryStore.posts.unshift(newPost);
|
||||
}
|
||||
|
||||
res.status(201).json({ ...newPost, date: formatDate(newPost.date) });
|
||||
} catch (error) {
|
||||
console.error('创建文章失败:', error);
|
||||
res.status(500).json({ error: '创建文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新文章
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, date, excerpt, content } = req.body;
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
memoryStore.posts[index] = { ...memoryStore.posts[index], title, date, excerpt, content };
|
||||
return res.json({ ...memoryStore.posts[index], date: formatDate(memoryStore.posts[index].date) });
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE posts SET title = ?, date = ?, excerpt = ?, content = ? WHERE id = ?',
|
||||
[title, date, excerpt || '', content || '', id]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
const updated = rows[0];
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
memoryStore.posts[index] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...updated, date: formatDate(updated.date) });
|
||||
} catch (error) {
|
||||
console.error('更新文章失败:', error);
|
||||
res.status(500).json({ error: '更新文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除文章
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
memoryStore.posts.splice(index, 1);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
const [result] = await pool.execute('DELETE FROM posts WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
memoryStore.posts = memoryStore.posts.filter(p => p.id !== id);
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('删除文章失败:', error);
|
||||
res.status(500).json({ error: '删除文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
216
server/src/routes/settings.js
Normal file
216
server/src/routes/settings.js
Normal file
@@ -0,0 +1,216 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
const rustfs = require('../config/rustfs');
|
||||
|
||||
// 生成唯一文件名
|
||||
function generateKey(prefix, id) {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${prefix}/${timestamp}-${random}-${id}`;
|
||||
}
|
||||
|
||||
// base64 数据解码为 Buffer
|
||||
function decodeBase64(dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:')) {
|
||||
return null;
|
||||
}
|
||||
const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) return null;
|
||||
return {
|
||||
mimeType: matches[1],
|
||||
data: Buffer.from(matches[2], 'base64')
|
||||
};
|
||||
}
|
||||
|
||||
// 获取设置 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据
|
||||
if (memoryStore.settings && memoryStore.settings.bgType) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
if (rows.length === 0) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
const formatted = formatSettings(rows[0]);
|
||||
memoryStore.settings = formatted;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('获取设置失败:', error.message);
|
||||
res.json(memoryStore.settings);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传单张图片到 RUSTFS
|
||||
router.post('/upload-image', async (req, res) => {
|
||||
try {
|
||||
const { imageData, type } = req.body; // type: 'background' | 'icon' | 'gallery'
|
||||
|
||||
if (!imageData || !imageData.startsWith('data:')) {
|
||||
return res.status(400).json({ error: '无效的图片数据' });
|
||||
}
|
||||
|
||||
const decoded = decodeBase64(imageData);
|
||||
if (!decoded) {
|
||||
return res.status(400).json({ error: '图片解码失败' });
|
||||
}
|
||||
|
||||
const id = Date.now().toString();
|
||||
const prefix = type || 'images';
|
||||
const key = generateKey(prefix, id);
|
||||
const url = await rustfs.uploadFile(decoded.data, key, decoded.mimeType);
|
||||
|
||||
console.log(`✅ 图片上传成功: ${url}`);
|
||||
|
||||
res.json({ url, id });
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error);
|
||||
res.status(500).json({ error: '图片上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量上传图片(并行上传到 RUSTFS)
|
||||
router.post('/upload-images', async (req, res) => {
|
||||
try {
|
||||
const { images, type } = req.body; // images: [{id, data}]
|
||||
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return res.json({ images: [] });
|
||||
}
|
||||
|
||||
const prefix = type || 'images';
|
||||
|
||||
// 并行上传所有图片
|
||||
const uploadPromises = images.map(async (img) => {
|
||||
try {
|
||||
const decoded = decodeBase64(img.data);
|
||||
if (!decoded) {
|
||||
return { id: img.id, url: null, error: '解码失败' };
|
||||
}
|
||||
|
||||
const key = generateKey(prefix, img.id);
|
||||
const url = await rustfs.uploadFile(decoded.data, key, decoded.mimeType);
|
||||
return { id: img.id, url };
|
||||
} catch (err) {
|
||||
console.error(`图片 ${img.id} 上传失败:`, err);
|
||||
return { id: img.id, url: null, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(uploadPromises);
|
||||
res.json({ images: results });
|
||||
} catch (error) {
|
||||
console.error('批量图片上传失败:', error);
|
||||
res.status(500).json({ error: '图片上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新设置
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
// 兼容 camelCase 和 snake_case
|
||||
const {
|
||||
bg_type, bgType,
|
||||
bg_color, bgColor,
|
||||
bg_image, bgImage,
|
||||
bg_opacity, bgOpacity,
|
||||
language,
|
||||
favicon,
|
||||
uploaded_images, uploadedImages,
|
||||
uploaded_icons, uploadedIcons
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
Object.assign(memoryStore.settings, {
|
||||
bg_type: bg_type || bgType,
|
||||
bg_color: bg_color || bgColor,
|
||||
bg_image: bg_image || bgImage,
|
||||
bg_opacity: bg_opacity ?? bgOpacity,
|
||||
language,
|
||||
favicon,
|
||||
uploaded_images: uploaded_images || uploadedImages,
|
||||
uploaded_icons: uploaded_icons || uploadedIcons
|
||||
});
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE settings SET
|
||||
bg_type = ?, bg_color = ?, bg_image = ?, bg_opacity = ?,
|
||||
language = ?, favicon = ?, uploaded_images = ?, uploaded_icons = ?
|
||||
WHERE id = 1`,
|
||||
[
|
||||
bg_type || bgType || 'color',
|
||||
bg_color || bgColor || '#f5f5f5',
|
||||
bg_image || bgImage || '',
|
||||
bg_opacity ?? bgOpacity ?? 1.00,
|
||||
language || 'zh',
|
||||
favicon || '',
|
||||
prepareJSON(uploaded_images || uploadedImages),
|
||||
prepareJSON(uploaded_icons || uploadedIcons)
|
||||
]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
const formatted = formatSettings(rows[0]);
|
||||
memoryStore.settings = formatted;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('更新设置失败:', error);
|
||||
res.status(500).json({ error: '更新设置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
function formatSettings(row) {
|
||||
if (!row) return memoryStore.settings;
|
||||
|
||||
return {
|
||||
bgType: row.bg_type,
|
||||
bgColor: row.bg_color,
|
||||
bgImage: row.bg_image,
|
||||
bgOpacity: row.bg_opacity,
|
||||
language: row.language,
|
||||
favicon: row.favicon,
|
||||
// uploadedImages 和 uploadedIcons 现在存储的是 {id, url} 数组
|
||||
uploadedImages: parseJSON(row.uploaded_images, []),
|
||||
uploadedIcons: parseJSON(row.uploaded_icons, [])
|
||||
};
|
||||
}
|
||||
|
||||
function parseJSON(str, defaultValue) {
|
||||
if (!str) return defaultValue;
|
||||
if (typeof str === 'object') return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareJSON(value) {
|
||||
if (!value) return '[]';
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
49
server/src/scripts/addIndexes.js
Normal file
49
server/src/scripts/addIndexes.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 数据库索引迁移脚本
|
||||
* 为已存在的表添加索引,提升查询性能
|
||||
* 运行方式: node src/scripts/addIndexes.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 为 posts 表的 date 字段添加索引
|
||||
console.log('📝 正在为 posts.date 添加索引...');
|
||||
try {
|
||||
await connection.query('CREATE INDEX idx_posts_date ON posts (date DESC)');
|
||||
console.log('✅ idx_posts_date 索引创建成功');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_KEYNAME') {
|
||||
console.log('ℹ️ idx_posts_date 索引已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 索引迁移完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 索引迁移失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
74
server/src/scripts/checkDb.js
Normal file
74
server/src/scripts/checkDb.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 数据库诊断脚本
|
||||
* 检查数据库中的实际数据
|
||||
* 运行方式: node src/scripts/checkDb.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function check() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...\n');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功\n');
|
||||
|
||||
// 检查表结构
|
||||
console.log('📋 表结构检查:');
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('现有表:', tables.map(t => Object.values(t)[0]).join(', '));
|
||||
|
||||
// 检查 settings 表结构和数据
|
||||
console.log('\n📊 settings 表数据:');
|
||||
try {
|
||||
const [settingsRows] = await connection.query('SELECT * FROM settings WHERE id = 1');
|
||||
if (settingsRows.length === 0) {
|
||||
console.log('settings 表为空或无数据');
|
||||
} else {
|
||||
const row = settingsRows[0];
|
||||
console.log(' id:', row.id);
|
||||
console.log(' bg_type:', row.bg_type);
|
||||
console.log(' bg_color:', row.bg_color);
|
||||
console.log(' bg_image:', row.bg_image ? `[base64, 长度: ${row.bg_image.length}]` : '(空)');
|
||||
console.log(' bg_opacity:', row.bg_opacity);
|
||||
console.log(' language:', row.language);
|
||||
console.log(' favicon:', row.favicon ? `[base64, 长度: ${row.favicon.length}]` : '(空)');
|
||||
console.log(' uploaded_images:', row.uploaded_images);
|
||||
console.log(' uploaded_icons:', row.uploaded_icons);
|
||||
console.log(' uploaded_images 类型:', typeof row.uploaded_images);
|
||||
console.log(' uploaded_icons 类型:', typeof row.uploaded_icons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('settings 表查询失败:', error.message);
|
||||
}
|
||||
|
||||
// 检查 posts 数量
|
||||
console.log('\n📝 posts 数量:');
|
||||
try {
|
||||
const [postCount] = await connection.query('SELECT COUNT(*) as count FROM posts');
|
||||
console.log('文章总数:', postCount[0].count);
|
||||
} catch (error) {
|
||||
console.log('posts 表查询失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n✅ 诊断完成');
|
||||
} catch (error) {
|
||||
console.error('❌ 诊断失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
165
server/src/scripts/initDb.js
Normal file
165
server/src/scripts/initDb.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
* 运行方式: node src/scripts/initDb.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
charset: 'utf8mb4'
|
||||
};
|
||||
|
||||
const DB_NAME = process.env.DB_NAME || 'jnote';
|
||||
|
||||
async function initDatabase() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 创建数据库
|
||||
console.log(`📦 正在创建数据库 ${DB_NAME}...`);
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
console.log(`✅ 数据库 ${DB_NAME} 创建成功`);
|
||||
|
||||
// 使用数据库
|
||||
await connection.query(`USE \`${DB_NAME}\``);
|
||||
|
||||
// 创建 posts 表 - content 改为 MEDIUMTEXT
|
||||
console.log('📝 正在创建 posts 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
excerpt TEXT,
|
||||
content MEDIUMTEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_posts_date (date DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ posts 表创建成功');
|
||||
|
||||
// 创建 about 表 - 使用 JSON 类型
|
||||
console.log('📋 正在创建 about 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS about (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
title VARCHAR(255) DEFAULT '关于我',
|
||||
intro TEXT,
|
||||
blog TEXT,
|
||||
tech TEXT,
|
||||
features JSON,
|
||||
contact JSON,
|
||||
filing_number VARCHAR(100),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ about 表创建成功');
|
||||
|
||||
// 创建 settings 表 - 使用 JSON 类型存储图片列表
|
||||
console.log('⚙️ 正在创建 settings 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
bg_type VARCHAR(20) DEFAULT 'color',
|
||||
bg_color VARCHAR(20) DEFAULT '#f5f5f5',
|
||||
bg_image TEXT,
|
||||
bg_opacity DECIMAL(3,2) DEFAULT 1.00,
|
||||
language VARCHAR(10) DEFAULT 'zh',
|
||||
favicon TEXT,
|
||||
uploaded_images JSON,
|
||||
uploaded_icons JSON,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ settings 表创建成功');
|
||||
|
||||
// 创建 cron_tasks 表
|
||||
console.log('⏰ 正在创建 cron_tasks 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS cron_tasks (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cron VARCHAR(50) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
method VARCHAR(20) DEFAULT 'GET',
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
last_run DATETIME,
|
||||
next_run DATETIME,
|
||||
run_count INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ cron_tasks 表创建成功');
|
||||
|
||||
// 初始化默认数据
|
||||
console.log('📄 正在初始化默认数据...');
|
||||
|
||||
// 初始化 about 默认数据
|
||||
await connection.query(`
|
||||
INSERT INTO about (id, title, intro, blog, tech, features, contact, filing_number)
|
||||
VALUES (1, '关于我', '你好!我是一名热爱技术开发的开发者。', '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
'["定时任务系统 - 支持 Cron表达式的自动化任务管理", "自定义背景 - 支持纯色和图片背景,可调节透明度", "多语言支持 - 中英文切换", "自定义图标 - 上传本地图片作为网站图标"]',
|
||||
'{"email": "example@email.com", "github": "github.com/yourusername"}',
|
||||
'桂ICP备2022004108号-1')
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
`);
|
||||
console.log('✅ about 默认数据初始化成功');
|
||||
|
||||
// 初始化 settings 默认数据
|
||||
await connection.query(`
|
||||
INSERT INTO settings (id, bg_type, bg_color, bg_opacity, language)
|
||||
VALUES (1, 'color', '#f5f5f5', 1.00, 'zh')
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
`);
|
||||
console.log('✅ settings 默认数据初始化成功');
|
||||
|
||||
// 初始化示例文章
|
||||
const samplePosts = [
|
||||
['欢迎来到我的编程学习记录', '2026-06-01', '这是我的第一篇文章,欢迎大家来访!', '这是我的第一篇文章,欢迎大家来访!在这里我会分享一些技术心得和生活感悟。希望大家喜欢这里的内容,有任何问题欢迎留言交流。'],
|
||||
['Vue 3 组合式 API 入门', '2026-06-02', '探索 Vue 3 的组合式 API,感受更好的代码组织方式。', 'Vue 3 引入了组合式 API,这是一种全新的代码组织方式。通过 setup 函数和响应式 API,我们可以更灵活地组织组件逻辑,提高代码的可复用性和可维护性。\n\n主要特点:\n1. 更好的逻辑复用\n2. 更灵活的代码组织\n3. 更好的 TypeScript 支持'],
|
||||
['静态网站生成器选型', '2026-06-03', '对比常见的静态网站生成器,帮助你选择合适的工具。', '选择一个合适的静态网站生成器需要考虑多个因素:\n\n1. Hexo - 简单易用,主题丰富\n2. Jekyll - GitHub Pages 原生支持,社区活跃\n3. Hugo - 构建速度极快\n4. VuePress / VitePress - 基于 Vue,适合技术文档\n\n本博客使用 Vue 3 + Vite 构建,兼具现代特性和开发体验。'],
|
||||
['优雅地使用 Pinia 管理状态', '2026-06-04', 'Pinia 是 Vue 3 推荐的状态管理方案,本篇介绍其基本用法。', 'Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 更加轻量和直观。\n\n核心概念:\n- Store - 存储状态的地方\n- Getters - 类似 computed 的状态计算\n- Actions - 处理异步逻辑或修改状态'],
|
||||
['CSS Grid 布局实战技巧', '2026-06-05', '深入探索 CSS Grid,让页面布局变得轻而易举。', 'CSS Grid 是现代 CSS 布局的重要组成部分,比 Flexbox 更适合二维布局场景。\n\n基础用法:\n.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }\n\n常用场景:\n1. 相册网格 - 自动填充列数\n2. 圣杯布局 - 经典三栏布局\n3. 响应式卡片 - 自动换行适配'],
|
||||
['用 Git 工作流提升团队协作效率', '2026-06-06', 'GitFlow vs trunk-based development,选对工作流事半功倍。', '良好的 Git 工作流能让团队协作更加顺畅。常见的工作流模型:\n\n1. GitFlow - 分支模型清晰,适合发布周期稳定的项目\n2. trunk-based development - 主干开发,快速迭代\n3. fork workflow - 开源项目首选,隔离性强\n\n小团队推荐使用简化 GitFlow:main + develop + feature 分支。'],
|
||||
['JavaScript 事件循环机制详解', '2026-06-07', '理解 Event Loop、宏任务与微任务,告别异步编程困惑。', '事件循环是 JavaScript 异步编程的核心机制。\n\n执行顺序:\n1. 同步代码优先执行\n2. 微任务(Promise、MutationObserver)\n3. 宏任务(setTimeout、setInterval、setImmediate)\n\n示例:\nconsole.log(\'1\');\nsetTimeout(() => console.log(\'2\'), 0);\nPromise.resolve().then(() => console.log(\'3\'));\nconsole.log(\'4\');\n\n输出顺序:1, 4, 3, 2'],
|
||||
['我的开发环境配置分享', '2026-06-08', 'VS Code + Zsh + Tmux,打造高效开发终端。', '工欲善其事,必先利其器。分享我的开发环境配置:\n\n编辑器:\n- VS Code + Vim 插件\n- Tokyo Night 主题\n- Fira Code 字体 + 连字\n\n终端:\n- iTerm2 (macOS) / Windows Terminal\n- Oh My Zsh\n- Tmux 会话管理\n\n版本管理:\n- Git + delta 美化 diff\n- lazygit 终端 UI'],
|
||||
['从零搭建一个 CLI 工具', '2026-06-09', '使用 Node.js 和 commander.js 快速构建命令行工具。', '命令行工具是提升开发效率的利器。使用 Node.js 可以快速构建:\n\n初始化项目:\nnpm init -y\nnpm install commander inquirer\n\n核心代码:\nconst { program } = require(\'commander\');\nprogram.version(\'1.0.0\');\nprogram.option(\'-n, --name <name>\', \'项目名称\');\nprogram.parse(process.argv);\n\n发布到 npm:\nnpm publish --access public'],
|
||||
['为什么我喜欢深夜编程', '2026-06-10', '安静、专注、不被打扰,深夜是程序员的黄金时段。', '深夜编程的独特魅力:\n\n1. 绝对安静 - 无人打扰,思路连贯\n2. 效率翻倍 - 深度工作状态\n3. 问题解决 - 复杂 bug 往往在这时候被攻破\n\n当然,也要适度。注意休息,保护眼睛,第二天才能持续输出。\n\n推荐配合:黑咖啡 + 轻音乐 + 舒适的降噪耳机。']
|
||||
];
|
||||
|
||||
for (const post of samplePosts) {
|
||||
await connection.query(
|
||||
'INSERT INTO posts (title, date, excerpt, content) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE title=title',
|
||||
post
|
||||
);
|
||||
}
|
||||
console.log('✅ 示例文章初始化成功(共10篇)');
|
||||
|
||||
console.log('\n🎉 数据库初始化完成!');
|
||||
console.log(`📖 数据库名: ${DB_NAME}`);
|
||||
console.log('📋 数据表: posts, about, settings, cron_tasks');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
49
server/src/scripts/migrateColumns.js
Normal file
49
server/src/scripts/migrateColumns.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 数据库字段扩展脚本
|
||||
* 将 TEXT 改为 LONGTEXT 以支持大图片
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
console.log('📝 正在修改 settings 表字段...');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN bg_image LONGTEXT');
|
||||
console.log('✅ bg_image 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN favicon LONGTEXT');
|
||||
console.log('✅ favicon 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_images LONGTEXT');
|
||||
console.log('✅ uploaded_images 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_icons LONGTEXT');
|
||||
console.log('✅ uploaded_icons 改为 LONGTEXT');
|
||||
|
||||
console.log('\n🎉 字段扩展完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 扩展失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
115
server/src/scripts/migrateStorage.js
Normal file
115
server/src/scripts/migrateStorage.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 数据存储结构迁移脚本
|
||||
* 将 posts.content 升级为 MEDIUMTEXT,about 和 settings 的 JSON 字段类型优化
|
||||
* 运行方式: node src/scripts/migrateStorage.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 1. 将 posts.content 升级为 MEDIUMTEXT
|
||||
console.log('📝 正在升级 posts.content 为 MEDIUMTEXT...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE posts MODIFY COLUMN content MEDIUMTEXT');
|
||||
console.log('✅ posts.content 已升级为 MEDIUMTEXT');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ posts.content 已是 MEDIUMTEXT 或不存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 将 about.features 改为 JSON 类型
|
||||
console.log('📋 正在将 about.features 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE about MODIFY COLUMN features JSON');
|
||||
console.log('✅ about.features 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ about.features 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ about.features 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将 about.contact 改为 JSON 类型
|
||||
console.log('📋 正在将 about.contact 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE about MODIFY COLUMN contact JSON');
|
||||
console.log('✅ about.contact 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ about.contact 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ about.contact 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 将 settings.uploaded_images 改为 JSON 类型
|
||||
console.log('⚙️ 正在将 settings.uploaded_images 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_images JSON');
|
||||
console.log('✅ settings.uploaded_images 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ settings.uploaded_images 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ settings.uploaded_images 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将 settings.uploaded_icons 改为 JSON 类型
|
||||
console.log('⚙️ 正在将 settings.uploaded_icons 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_icons JSON');
|
||||
console.log('✅ settings.uploaded_icons 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ settings.uploaded_icons 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ settings.uploaded_icons 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 添加 posts.date 索引
|
||||
console.log('📊 正在添加 posts.date 索引...');
|
||||
try {
|
||||
await connection.query('CREATE INDEX idx_posts_date ON posts (date DESC)');
|
||||
console.log('✅ idx_posts_date 索引创建成功');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_KEYNAME') {
|
||||
console.log('ℹ️ idx_posts_date 索引已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 存储结构迁移完成!');
|
||||
console.log('\n建议:考虑将图片等大文件存储到文件服务器,而非数据库。');
|
||||
} catch (error) {
|
||||
console.error('❌ 迁移失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
134
server/start.js
Normal file
134
server/start.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* JNote 后端启动脚本
|
||||
* 跨平台兼容 Windows 和 Linux
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const scriptDir = __dirname;
|
||||
process.chdir(scriptDir);
|
||||
|
||||
const APP_NAME = 'jnote-api';
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Start] ${msg}`);
|
||||
}
|
||||
|
||||
function loadEnv() {
|
||||
const envFile = path.join(scriptDir, '.env');
|
||||
if (!fs.existsSync(envFile)) {
|
||||
log('⚠️ 未找到 .env 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(envFile, 'utf8');
|
||||
content.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx > 0) {
|
||||
const key = line.substring(0, idx).trim();
|
||||
let value = line.substring(idx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getExePath() {
|
||||
const possibleNames = isWindows
|
||||
? ['jnote-api.exe', 'jnote-api']
|
||||
: ['jnote-api', 'jnote-api.exe'];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const p = path.join(scriptDir, name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function startWithPM2(exePath) {
|
||||
log('🚀 使用 PM2 启动...');
|
||||
|
||||
try {
|
||||
execSync('pm2 --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
log('📦 安装 PM2...');
|
||||
execSync('npm install -g pm2', { cwd: scriptDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`pm2 stop ${APP_NAME}`, { stdio: 'ignore' });
|
||||
execSync(`pm2 delete ${APP_NAME}`, { stdio: 'ignore' });
|
||||
} catch {}
|
||||
|
||||
spawn('pm2', ['start', exePath, '--name', APP_NAME], {
|
||||
cwd: scriptDir,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
try { execSync('pm2 save', { stdio: 'ignore' }); } catch {}
|
||||
}, 2000);
|
||||
|
||||
log('✅ 服务已启动');
|
||||
log(' 状态: pm2 status');
|
||||
log(' 日志: pm2 logs jnote-api');
|
||||
}
|
||||
|
||||
function startDirect(exePath) {
|
||||
log('🚀 直接启动服务...');
|
||||
log(` 端口: ${process.env.PORT || 3000}`);
|
||||
log('');
|
||||
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(exePath, 0o755);
|
||||
}
|
||||
|
||||
spawn(exePath, [], {
|
||||
cwd: scriptDir,
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端启动脚本');
|
||||
console.log(` 平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
loadEnv();
|
||||
|
||||
const exe = getExePath();
|
||||
if (!exe) {
|
||||
console.error('❌ 错误: 未找到 jnote-api 可执行文件');
|
||||
console.error(` 请确保可执行文件与本脚本在同一目录下: ${scriptDir}`);
|
||||
console.error('');
|
||||
console.error(' Linux 应有: jnote-api');
|
||||
console.error(' Windows 应有: jnote-api.exe');
|
||||
console.error('');
|
||||
console.error(' 如果还没有打包,请先运行: node build.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log(`✓ 找到: ${path.basename(exe)}`);
|
||||
|
||||
// 优先使用 PM2
|
||||
try {
|
||||
execSync('pm2 --version', { stdio: 'ignore' });
|
||||
startWithPM2(exe);
|
||||
} catch {
|
||||
startDirect(exe);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user