初始化后端框架,实现简易接口

This commit is contained in:
2025-10-15 18:24:12 +08:00
commit 3310deb638
9 changed files with 1704 additions and 0 deletions

10
.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Server
PORT=3000
# MySQL
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=your_database
MYSQL_CONNECTION_LIMIT=10

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Dependencies
node_modules/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna.log
# Env & secrets (keep .env.example tracked)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
*.key
*.pem
*.crt
# Build outputs & caches
dist/
build/
out/
coverage/
.nyc_output/
.tmp/
.temp/
tmp/
temp/
.cache/
# Editors & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
# Runtime
*.pid
*.pid.lock
# Swap/backup
*.swp
*.swo
*.orig

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# Fjeei Node.js API
## 快速开始
1. 安装依赖
- `npm install`
2. 配置环境
- 复制 `.env.example``.env`
- 按需填写 `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
3. 运行服务
- 开发启动:`npm run dev`
- 生产启动:`npm start`
4. 测试接口
- 健康检查:`GET http://localhost:3000/api/health`
- 数据库连接测试:`GET http://localhost:3000/api/db/ping`
- 新增文章:`POST http://localhost:3000/api/articles`
- 获取文章详情:`GET http://localhost:3000/api/articles/:id`
- 获取文章列表:`GET http://localhost:3000/api/articles?is_published=1&page=1&pageSize=20`
## 说明
- 使用 `mysql2/promise` 创建连接池,接口内执行轻量查询 `SELECT 1 AS ok` 测试连接。
-`.env` 未设置数据库,某些 MySQL 实例仍允许执行该查询;建议配置具体数据库以避免权限问题。
- 生产环境下错误信息不显示具体细节,开发环境会返回错误信息以便排查。
## 中文变问号排查
- 现象:保存/查询中文变成 `?`
- 原因:连接或表/库字符集不是 `utf8mb4`(常见为 `latin1`)。
- 解决:
1) 连接设置:本项目已在 `src/db.js` 中设置 `charset: 'utf8mb4'`
2) 表/库设置:执行 `sql/charset_fix.sql`(将 `your_database` 替换为实际库名),把数据库与 `articles` 表转换为 `utf8mb4`
3) 已经保存为问号的数据无法自动恢复,需要重新写入原始中文内容。
## 文章接口说明
- 新增文章(传输字段为文章表所有字段)
- `POST /api/articles`
- BodyJSON示例
```json
{
"title": "示例标题",
"title_color": "#ff0000",
"source": "新华社",
"author": "张三",
"url": "https://example.com/article/123",
"publish_time": "2025-10-15T10:00:00Z",
"content": "正文内容...",
"summary": "摘要...",
"cover_image": "https://example.com/cover.jpg",
"sort_number": 10,
"view_count": 0,
"is_published": 1,
"is_top": 0,
"is_recommended": 1,
"is_hot": 0,
"is_slideshow": 0,
"seo_title": "SEO 标题",
"seo_keywords": "关键字1,关键字2",
"seo_description": "SEO 描述"
}
```
- 返回:`{ "success": true, "id": <新文章ID> }`
- 获取文章详情
- `GET /api/articles/:id`
- 返回:`{ "success": true, "data": { id, title, ... } }`
- 获取文章列表(可选)
- `GET /api/articles?is_published=1&page=1&pageSize=20`
- 返回:`{ "success": true, "page": 1, "pageSize": 20, "data": [ ... ] }`

1332
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "fjeei-nodejs",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon -q src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^17.2.3",
"express": "^5.1.0",
"mysql2": "^3.15.2"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

25
sql/charset_fix.sql Normal file
View File

@@ -0,0 +1,25 @@
-- 查看当前字符集/排序规则
SHOW VARIABLES LIKE 'character_set_%';
SHOW VARIABLES LIKE 'collation%';
-- 将数据库改为 utf8mb4将 your_database 替换为实际库名)
ALTER DATABASE `your_database` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 将 articles 表改为 utf8mb4
ALTER TABLE `articles` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 可选:逐列确保为 utf8mb4如前一步失败或只想改文本列
ALTER TABLE `articles`
MODIFY `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
MODIFY `title_color` VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `source` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `author` VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `url` VARCHAR(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `content` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
MODIFY `summary` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `cover_image` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `seo_title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `seo_keywords` VARCHAR(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
MODIFY `seo_description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL;
-- 注意:已有被保存为问号的数据无法自动恢复,需要重新写入原始中文内容。

33
src/db.js Normal file
View File

@@ -0,0 +1,33 @@
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
// 获取环境变量
dotenv.config();
// 创建 MySQL 连接池
const pool = mysql.createPool({
host: process.env.MYSQL_HOST || '127.0.0.1',
port: parseInt(process.env.MYSQL_PORT || '3306', 10),
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '',
// Some MySQL servers allow connecting without specifying database for simple queries
database: process.env.MYSQL_DATABASE || undefined,
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: parseInt(process.env.MYSQL_CONNECTION_LIMIT || '10', 10),
queueLimit: 0,
});
// 测试数据库连接
async function testConnection() {
let conn;
try {
conn = await pool.getConnection();
const [rows] = await conn.query('SELECT 1 AS ok');
return { ok: rows && rows[0] && rows[0].ok === 1 };
} finally {
if (conn) conn.release();
}
}
module.exports = { pool, testConnection };

38
src/index.js Normal file
View File

@@ -0,0 +1,38 @@
const express = require('express');
const dotenv = require('dotenv');
const { testConnection } = require('./db');
const articlesRouter = require('./routes/articles');
// 获取环境变量
dotenv.config();
const app = express();
app.use(express.json());
const port = parseInt(process.env.PORT || '3000', 10);
// 健康检查接口
app.get('/api/health', (req, res) => {
res.json({ ok: true, uptime: process.uptime() });
});
// 数据库连接测试接口
app.get('/api/db/ping', async (req, res) => {
try {
const result = await testConnection();
res.json({ success: result.ok, message: result.ok ? 'MySQL连接成功' : 'MySQL连接失败' });
} catch (err) {
res.status(500).json({
success: false,
message: '连接MySQL失败',
error: process.env.NODE_ENV === 'production' ? undefined : err.message,
});
}
});
// 文章相关接口
app.use('/api', articlesRouter);
app.listen(port, () => {
console.log(`API server listening on http://localhost:${port}`);
});

122
src/routes/articles.js Normal file
View File

@@ -0,0 +1,122 @@
const express = require('express');
const { pool } = require('../db');
const router = express.Router();
const columns = [
'title',
'title_color',
'source',
'author',
'url',
'publish_time',
'content',
'summary',
'cover_image',
'sort_number',
'view_count',
'is_published',
'is_top',
'is_recommended',
'is_hot',
'is_slideshow',
'seo_title',
'seo_keywords',
'seo_description',
];
function toTinyInt(v) {
return v === true || v === 1 || v === '1' ? 1 : 0;
}
function normalizePayload(body) {
const payload = {};
// Required fields
if (!body.title || !body.content) {
return { error: '缺少必要字段title 或 content' };
}
payload.title = body.title;
payload.title_color = body.title_color ?? null;
payload.source = body.source ?? null;
payload.author = body.author ?? null;
payload.url = body.url ?? null;
payload.publish_time = body.publish_time ? new Date(body.publish_time) : null;
payload.content = body.content;
payload.summary = body.summary ?? null;
payload.cover_image = body.cover_image ?? null;
payload.sort_number = body.sort_number != null ? parseInt(body.sort_number, 10) || 0 : 0;
payload.view_count = body.view_count != null ? parseInt(body.view_count, 10) || 0 : 0;
payload.is_published = toTinyInt(body.is_published);
payload.is_top = toTinyInt(body.is_top);
payload.is_recommended = toTinyInt(body.is_recommended);
payload.is_hot = toTinyInt(body.is_hot);
payload.is_slideshow = toTinyInt(body.is_slideshow);
payload.seo_title = body.seo_title ?? null;
payload.seo_keywords = body.seo_keywords ?? null;
payload.seo_description = body.seo_description ?? null;
return { payload };
}
// Create article
router.post('/articles', async (req, res) => {
try {
const { payload, error } = normalizePayload(req.body || {});
if (error) {
return res.status(400).json({ success: false, message: error });
}
const placeholders = columns.map(() => '?').join(',');
const values = columns.map((c) => payload[c] ?? null);
const sql = `INSERT INTO articles (${columns.join(',')}) VALUES (${placeholders})`;
const [result] = await pool.execute(sql, values);
return res.json({ success: true, id: result.insertId });
} catch (err) {
return res.status(500).json({ success: false, message: '新增文章失败', error: err.message });
}
});
// Get article by id
router.get('/articles/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!id) return res.status(400).json({ success: false, message: 'id不合法' });
const sql = `SELECT id, ${columns.join(',')} FROM articles WHERE id = ? LIMIT 1`;
const [rows] = await pool.execute(sql, [id]);
if (!rows || rows.length === 0) {
return res.status(404).json({ success: false, message: '文章不存在' });
}
return res.json({ success: true, data: rows[0] });
} catch (err) {
return res.status(500).json({ success: false, message: '获取文章失败', error: err.message });
}
});
// Get article list (optional)
router.get('/articles', async (req, res) => {
try {
const pageSize = Math.min(parseInt(req.query.pageSize || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
const offset = (page - 1) * pageSize;
const where = [];
const params = [];
if (req.query.is_published != null) {
where.push('is_published = ?');
params.push(toTinyInt(req.query.is_published));
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const sql = `SELECT id, ${columns.join(',')} FROM articles ${whereSql} ORDER BY is_top DESC, sort_number DESC, publish_time DESC LIMIT ${pageSize} OFFSET ${offset}`;
const [rows] = await pool.execute(sql, params);
return res.json({ success: true, page, pageSize, data: rows });
} catch (err) {
return res.status(500).json({ success: false, message: '获取文章列表失败', error: err.message });
}
});
module.exports = router;