初始化后端框架,实现简易接口
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal 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
48
.gitignore
vendored
Normal 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
74
README.md
Normal 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`
|
||||
- Body(JSON)示例:
|
||||
```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
1332
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal 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
25
sql/charset_fix.sql
Normal 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
33
src/db.js
Normal 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
38
src/index.js
Normal 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
122
src/routes/articles.js
Normal 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;
|
||||
Reference in New Issue
Block a user