
新闻网站源码的目录结构怎么设计:从栏目、文章到后台管理
新闻网站看起来是“栏目列表加文章详情”,但代码很快会碰到更多问题:栏目是否可以嵌套,文章发布后能不能修改,后台用户如何授权,图片存在哪里,搜索和分页怎样限制资源消耗。目录结构如果只按页面名称划分,初期能跑,功能增加后往往变成相互引用、权限混杂、改一处坏几处。
本文以常见的 Node.js 网站为例,介绍一套适合中小型新闻站点的目录思路,并提供可运行的 Express 与 SQLite 示例。重点不是追求某种“标准架构”,而是让栏目、文章、后台和基础设施各有边界:简单项目不要过度拆分,涉及审核、多人协作或多站点时则要提前留出扩展位置。
一、先判断项目适合什么结构
目录设计应从业务复杂度出发,而不是先选微服务或框架。下面的结构适合一个代码仓库维护、一个主要新闻站点、由团队共同开发的项目:
- 前台展示栏目、文章、专题等内容,后台负责内容编辑和发布。
- 服务端有明确的路由、业务规则和数据访问代码。
- 文章和栏目由同一个团队维护,暂时不需要独立部署。
如果只是几页静态内容,可以采用更简单的静态站点结构;如果多个站点共用内容、需要独立审核工作流或有大量并发读取,再考虑内容服务、搜索服务等进一步拆分。不要因为项目名称里有“新闻”就默认需要微服务:网络调用、部署、监控和数据一致性都会增加复杂度。
二、按职责划分目录,而不是按文件类型堆放
一个可作为起点的目录结构如下。它表达的是职责边界,不要求每个目录一开始都建齐。
news-site/
├── package.json
├── src/
│ ├── app.js
│ ├── config/
│ │ └── index.js
│ ├── routes/
│ │ ├── public.js
│ │ └── admin.js
│ ├── modules/
│ │ ├── categories/
│ │ │ ├── category.repository.js
│ │ │ └── category.service.js
│ │ └── articles/
│ │ ├── article.repository.js
│ │ └── article.service.js
│ ├── middleware/
│ │ ├── authenticate.js
│ │ └── authorize.js
│ ├── db/
│ │ ├── connection.js
│ │ └── migrations/
│ ├── views/
│ │ ├── public/
│ │ └── admin/
│ └── utils/
├── public/
│ └── assets/
└── storage/
└── uploads/
routes 负责把 HTTP 请求交给相应功能;modules 按业务领域组织规则和数据访问;db 管理连接与迁移;middleware 放身份认证、授权等跨路由逻辑。模板页面、公开静态资源和上传文件也要区分:上传目录不应因为方便就直接等同于可执行代码目录。
一个小型项目可以先把模块压缩成少量文件,但仍应守住依赖方向:路由调用业务服务,业务服务调用数据访问层;数据库层不应反过来依赖路由或页面模板。这样以后拆文件时,业务规则不必从 HTTP 处理代码里重新挖出来。
三、栏目与文章:先把关系和规则说清楚
栏目不是页面路由的别名
栏目通常有名称、唯一标识、排序、状态和可选的父栏目。数据库中的栏目记录描述内容分类;URL 只是对外访问形式。例如 /news/local 可以对应一个 slug 为 local 的栏目,但不要把栏目名称直接拼进文件路径或 SQL 语句。
栏目是否允许树形结构,应由实际需求决定。只有一级分类时,保存 parent_id 会增加无用复杂度;需要“新闻 / 本地”这样的层级时,父子关系有用,但还要明确是否允许移动栏目、删除有子栏目或已有文章的栏目,以及移动后 URL 是否变化。栏目 slug 的唯一范围也要先定:全站唯一还是同一父栏目下唯一。示例采用全站唯一,便于公开 URL 查找。
文章至少要区分内容与发布状态
文章常见字段包括标题、摘要、正文、作者、所属栏目、封面、状态和发布时间。状态不能只靠“发布时间是否为空”推断:草稿、待审核、已发布、已撤下表达的是不同业务状态。公开列表应显式筛选已发布内容,后台则按用户权限查看草稿或待审核内容。
如果一篇文章只能属于一个栏目,文章表保存 category_id 足够直接。如果文章可以出现在多个栏目,应该使用关联表,而不是在单个字段中塞入逗号分隔的 ID。栏目删除也要采用明确策略:限制删除、先迁移文章,或软删除栏目;不要依赖级联删除让内容意外消失。
以下是 SQLite 示例所需的最小表结构。生产项目应通过迁移管理数据库变更,而不是每次启动都手动改表。
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id INTEGER REFERENCES categories(id),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(id),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
summary TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'withdrawn')),
published_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_public
ON articles(status, published_at DESC);
CREATE INDEX idx_articles_category
ON articles(category_id, status, published_at DESC);
索引要结合实际查询设计;索引不是越多越好,因为写入和存储也会付出代价。SQLite 的外键约束还需要在连接上启用 PRAGMA foreign_keys = ON,否则仅声明外键并不等于所有连接都会执行约束检查。使用其他数据库时,应查阅对应数据库的外键和索引行为。
四、让后台管理成为独立的访问边界
前台和后台可以共用文章、栏目等领域模块,但路由入口和权限检查必须区分。典型后台功能包括登录、栏目管理、文章编辑、预览、审核、发布和操作记录。不要因为页面地址以 /admin 开头,就认为它已经安全;路径前缀不是认证机制。
建议把“身份认证”和“操作授权”分开:认证确认请求对应哪个用户,授权判断该用户能不能执行某项操作。编辑文章、审核文章、管理栏目可以是不同权限。审核和发布最好通过明确的状态转换完成,例如待审核文章只能由有审核权限的用户发布,而不是任意更新一个状态字段。
后台写入接口还要考虑会话或令牌的保护、CSRF(适用基于 Cookie 的会话时)、登录限速、密码安全存储和操作审计。下面的示例只开放公开读取接口,不包含后台写入,也没有实现登录系统,因此不能把它当作完整的后台安全方案。真实项目应在后台路由上接入经过验证的认证和授权中间件。
五、一个可运行的公开接口示例
下面的最小示例使用 Node.js、Express 和 better-sqlite3。它实现栏目文章列表和文章详情查询,能直接运行;数据库表和少量演示数据会在启动时创建。这里使用 SQLite 是为了让示例容易本地验证,并不代表它适合所有生产负载。
创建 package.json:
{
"name": "news-site-demo",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node src/app.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"express": "^4.21.0"
}
}
创建 src/app.js:
const express = require('express');
const Database = require('better-sqlite3');
const app = express();
const db = new Database('news.sqlite');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(id),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
summary TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('draft', 'published', 'withdrawn')),
published_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_articles_public
ON articles(status, published_at DESC);
`);
const categoryCount = db.prepare(
'SELECT COUNT(*) AS count FROM categories'
).get().count;
if (categoryCount === 0) {
const insertCategory = db.prepare(
'INSERT INTO categories (name, slug) VALUES (?, ?)'
);
const result = insertCategory.run('本地', 'local');
db.prepare(`
INSERT INTO articles
(category_id, title, slug, summary, body, status, published_at)
VALUES (?, ?, ?, ?, ?, 'published', CURRENT_TIMESTAMP)
`).run(
result.lastInsertRowid,
'示例新闻',
'sample-news',
'这是一条用于验证接口的示例。',
'这里是示例正文。真实项目还需要处理富文本安全和内容审核。'
);
}
app.get('/api/articles', (req, res) => {
const rawPage = req.query.page ?? '1';
const rawLimit = req.query.limit ?? '10';
if (
typeof rawPage !== 'string' ||
typeof rawLimit !== 'string' ||
!/^[1-9]\\d*$/.test(rawPage) ||
!/^[1-9]\\d*$/.test(rawLimit)
) {
return res.status(400).json({ error: 'page 和 limit 必须是正整数' });
}
const page = Number(rawPage);
const limit = Number(rawLimit);
if (
!Number.isSafeInteger(page) ||
!Number.isSafeInteger(limit) ||
page > 100000 ||
limit > 50
) {
return res.status(400).json({ error: '分页参数超出允许范围' });
}
const categorySlug = req.query.category;
if (categorySlug !== undefined && typeof categorySlug !== 'string') {
return res.status(400).json({ error: 'category 必须是单个字符串' });
}
const conditions = [
"a.status = 'published'",
'c.is_active = 1',
'a.published_at IS NOT NULL',
'a.published_at <= CURRENT_TIMESTAMP'
];
const values = [];
if (categorySlug !== undefined) {
conditions.push('c.slug = ?');
values.push(categorySlug);
}
const offset = (page - 1) * limit;
const rows = db.prepare(`
SELECT a.id, a.title, a.slug, a.summary, a.published_at,
c.name AS category_name, c.slug AS category_slug
FROM articles AS a
JOIN categories AS c ON c.id = a.category_id
WHERE ${conditions.join(' AND ')}
ORDER BY a.published_at DESC, a.id DESC
LIMIT ? OFFSET ?
`).all(...values, limit, offset);
res.json({ page, limit, items: rows });
});
app.get('/api/articles/:slug', (req, res) => {
const article = db.prepare(`
SELECT a.id, a.title, a.slug, a.summary, a.body, a.published_at,
c.name AS category_name, c.slug AS category_slug
FROM articles AS a
JOIN categories AS c ON c.id = a.category_id
WHERE a.slug = ?
AND a.status = 'published'
AND c.is_active = 1
AND a.published_at IS NOT NULL
AND a.published_at <= CURRENT_TIMESTAMP
`).get(req.params.slug);
if (!article) {
return res.status(404).json({ error: '文章不存在' });
}
res.json({ item: article });
});
app.listen(3000, () => {
console.log('News demo listening on http://localhost:3000');
});
在项目根目录执行 npm install,再执行 npm start。可访问 http://localhost:3000/api/articles 获取列表,或访问 http://localhost:3000/api/articles/sample-news 获取示例详情。数据库文件 news.sqlite 会出现在启动命令所在目录。
六、示例里的参数限制和安全边界
page 和 limit 的校验拒绝零、负数、小数、数组形式及非数字字符串,并额外检查 JavaScript 安全整数范围。示例把每页上限设为 50,把页码上限设为 100000;这是该示例为了限制单次查询成本做的应用约束,不是 Express、SQLite 或 HTTP 协议规定的固定上限。实际数值应按数据库性能、数据量和产品需求调整。
即使页码通过校验,较大的 OFFSET 仍可能导致数据库扫描成本增加。数据量大、翻页很深时,可考虑基于发布时间和 ID 的游标分页,并对游标格式和查询范围进行验证。还应设置请求体大小限制、接口限流和合理的超时策略;这些措施需要结合部署环境,不能由一段分页校验代码替代。
SQL 中的 ? 是参数占位符,栏目 slug 和分页数字作为值绑定,可以避免把这些值拼接成 SQL 语法。这不等于“所有输入都安全”:参数绑定通常不能用来代替表名、列名或排序方向;这些 SQL 标识符若需动态选择,应该使用固定白名单。示例中的动态 SQL 只拼接程序固定的条件片段,用户提供的栏目 slug 仍通过参数绑定。
文章正文如果包含 HTML,直接把字符串插入网页可能造成跨站脚本攻击。JSON 接口本身不负责替调用方安全地渲染 HTML;前端应按文本展示,或者使用维护良好、配置合适的 HTML 清理库处理允许的标签和属性。模板引擎的自动转义也应保持启用。不要把“数据库存储了内容”误认为内容可信。
七、栏目、文章和后台进一步扩展时怎么拆
把业务规则放进服务层
示例为了篇幅将查询放在路由中。实际项目可以把列表查询放到 article.repository.js,把“哪些文章可以公开”“发布时必须具备哪些字段”等规则放到 article.service.js。路由只做参数解析、调用服务和组织 HTTP 响应。这样后台预览、公开详情和定时发布可以复用规则,但要注意它们的权限与可见范围并不相同。
区分内容状态、栏目状态和可见性
文章是否发布与栏目是否启用是两项不同状态。文章已发布但栏目停用时,公开接口是否隐藏文章,需要成为明确规则;上例选择同时检查已发布文章和启用栏目。后台可能仍需查看已停用栏目中的文章,因此后台查询不能简单复用公开查询。预览未发布文章时,应通过有授权的预览接口提供,而不是临时把文章状态改成已发布。
为媒体文件设置独立策略
上传目录用于保存文件,不应直接信任客户端传来的文件名、扩展名或 MIME 类型。常见做法是服务端生成存储名,校验允许的文件类型和大小,并把元数据(存储键、尺寸、上传者等)保存到数据库。若使用对象存储,应用保存对象标识和访问策略,不必把文件内容塞进文章表。具体大小上限、图片格式和访问控制应按业务与基础设施确定,不能只依赖浏览器端限制。
八、常见错误及其后果
- 把所有代码放进一个路由文件。 页面渲染、SQL、权限与业务状态交织,测试和复用困难。小项目可以少建文件,但应保持职责清楚。
- 把栏目名写死在模板或代码分支里。 新增、改名和排序都要改代码。栏目通常应作为数据维护,并为 slug 变更制定 URL 兼容策略。
- 把草稿和已发布文章混在公开查询中。 隐藏按钮不是权限控制。服务端的公开查询必须显式过滤状态和发布时间。
- 用字符串拼接用户输入构造 SQL。 这会造成 SQL 注入风险。值应参数化;动态字段和排序选项应使用白名单。
- 认为认证等于授权。 用户成功登录不意味着可以编辑所有栏目或执行审核操作。每项管理操作都应检查具体权限。
- 把上传目录当成静态资源目录直接开放。 这可能泄露不应公开的文件,也会扩大恶意文件造成的风险。公开展示应通过受控的静态存储或文件服务完成。
- 没有迁移和备份计划。 直接修改生产表结构、误删栏目或覆盖数据库都可能造成内容损失。变更要可追踪,备份也要定期验证能否恢复。
- 过早拆成多个服务。 小团队可能因此要同时维护服务发现、远程调用和分布式排错。先通过模块边界保持代码可拆分,出现明确的独立扩展或部署需求后再拆服务。
九、上线前的检查清单
- 公开接口是否只返回已发布、到达发布时间且符合栏目可见规则的文章?
- 后台写入、审核、删除和预览是否分别做认证与授权?
- 文章 HTML 是否安全处理,后台表单是否有合适的请求保护?
- 分页、搜索和请求体是否有合理限制,深分页是否会拖慢数据库?
- 数据库变更是否有迁移记录,备份是否做过恢复演练?
- 上传文件是否经过服务端校验,存储位置是否与应用代码隔离?
- 日志是否记录必要的错误和管理操作,同时避免写入密码、令牌等敏感信息?
结语
新闻网站目录结构的核心不是目录数量,而是边界是否清楚:栏目负责分类关系,文章模块负责内容和发布规则,后台负责受权限保护的管理操作,数据库和媒体存储由基础设施层管理。先用清晰的模块化单体满足业务,再根据真实的协作、性能和部署需求拆分,通常比一开始追求复杂架构更稳妥。




