
新闻网站源码的栏目与文章管理功能如何修改
新闻网站的栏目与文章管理,看起来只是后台表单和列表,实际修改时往往牵涉数据库结构、前台查询、权限、缓存、URL 规则和历史数据。仅改一个页面上的栏目名称,可能导致文章失去归属;把“删除”按钮改成级联删除,也可能让整批内容消失。
本文以常见的关系型数据库和 PHP PDO 为例,说明如何在现有新闻源码中安全地扩展栏目与文章管理。示例采用 SQLite,便于演示;如果项目使用 MySQL、Laravel、ThinkPHP 或其他框架,应将建表与连接代码替换为项目已有的迁移、模型和数据库访问方式。代码展示的是核心实现,不包含完整后台模板和项目级登录系统。
一、先确认要改的范围
开始写代码前,先把“栏目管理”和“文章管理”的含义定清楚。不同源码中的同名功能可能并不相同:有的栏目是两级分类,有的支持无限层级;有的文章只能属于一个栏目,有的允许同时关联多个栏目;有的删除栏目会删除文章,有的会把文章转移到默认栏目。
- 栏目:是否需要父子关系、排序、启用状态、栏目页标题、固定链接、栏目图片等字段?
- 文章:是否需要摘要、封面、作者、来源、发布时间、草稿和定时发布?文章是否只能属于一个栏目?
- 前台:列表页、详情页、导航栏、搜索、站点地图和 RSS 是否依赖这些字段?
- 权限:普通编辑能否管理所有栏目?删除文章是否需要更高权限?
- 兼容性:旧文章有没有栏目为空、重复别名或已失效栏目等历史数据?
还要先搜索源码中的表名、字段名和路由。例如旧项目可能使用 classid 表示栏目 ID,后台修改字段后,前台查询仍在读取 classid。功能是否完成,不能只看编辑页能否保存,也要检查列表、详情和导航等读取位置。
二、设计清楚的数据关系
下面的简化设计规定:每篇文章属于一个栏目;栏目可以有父栏目;文章删除采用软删除,栏目删除则采用“有文章时禁止删除”。这个约定避免栏目被删后文章失去归属,也为误删文章保留恢复空间。
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER NULL REFERENCES categories(id),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 DEFAULT 'draft'
CHECK (status IN ('draft', 'published')),
published_at TEXT NULL,
deleted_at TEXT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_articles_category_status
ON articles(category_id, status, published_at);
CREATE INDEX idx_articles_deleted_at
ON articles(deleted_at);
外键能帮助数据库阻止部分无效关联,但前提是数据库确实启用了外键检查;SQLite 连接通常需要执行 PRAGMA foreign_keys = ON。应用层仍需检查栏目是否存在、是否允许使用。CHECK 约束也不替代业务校验:例如它限制了文章状态的可选值,却不能决定谁有权限发布文章。
slug 是用于 URL 的稳定标识,示例要求全局唯一。若项目允许不同栏目下出现相同 slug,可改为组合唯一约束,并同步调整查询和 URL 解析逻辑。若 URL 使用数字 ID,则不一定需要 slug,但仍应使用稳定、明确的路由设计。
现有项目不要直接照搬建表语句覆盖生产数据库。应使用项目的迁移机制,先备份,再增加字段或索引,并检查旧数据是否满足新约束。把已有的可空字段改成必填之前,先补齐数据;否则迁移可能失败。
三、连接数据库与基础安全规则
下面的 PHP 示例可用于 PHP 8 环境。将它保存为 db.php,同目录创建可写的 data 目录。生产项目应从环境变量或受保护的配置中读取数据库连接信息,不要把密码提交到公开代码仓库。
<?php
declare(strict_types=1);
$pdo = new PDO(
'sqlite:' . __DIR__ . '/data/news.sqlite',
null,
null,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
$pdo->exec('PRAGMA foreign_keys = ON');
function e(?string $value): string
{
return htmlspecialchars(
$value ?? '',
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
e() 用于把不可信文本安全地放进 HTML 文本或带引号的属性值中。它不是输入验证,也不应被用来“净化”后再保存文章正文。纯文本标题在输出时转义即可;如果文章正文允许 HTML,则需要使用经过审查的 HTML 清洗器和明确的标签、属性白名单,不能只调用 htmlspecialchars() 后又把原始内容输出为 HTML。
管理端所有写操作都必须先通过项目已有的身份认证和权限检查。下面用占位函数表示这一层,实际项目需要接入自己的用户系统。不能因为接口路径不显眼,就把它当成访问控制。
<?php
function requireAdmin(): void
{
// 替换为项目的认证与授权逻辑。
// 例如:检查登录会话,并确认用户拥有内容管理权限。
if (empty($_SESSION['user_id']) ||
empty($_SESSION['can_manage_content'])) {
http_response_code(403);
exit('Forbidden');
}
}
这段代码只演示授权检查的接口位置,并不是完整的登录系统。真实项目还要安全设置会话 Cookie、登录状态更新和退出流程,并为不同角色划分权限。不要在生产环境里用一个固定口令或可预测的会话变量代替认证。
四、栏目列表、新增与修改
栏目编辑通常至少需要检查名称、slug、排序值和父栏目。以下函数使用 PDO 预处理语句写入数据。预处理语句可以安全绑定值,但不能把表名、列名或排序方向作为普通参数绑定;如果需要动态选择排序字段,必须使用代码中的白名单映射。
<?php
function saveCategory(
PDO $pdo,
?int $id,
?int $parentId,
string $name,
string $slug,
int $sortOrder,
bool $active
): int {
$name = trim($name);
$slug = trim($slug);
if ($name === '' || mb_strlen($name) > 120) {
throw new InvalidArgumentException('栏目名称不能为空且不能超过 120 个字符');
}
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) {
throw new InvalidArgumentException('slug 格式不正确');
}
if ($parentId !== null) {
if ($id !== null && $parentId === $id) {
throw new InvalidArgumentException('栏目不能将自己设为父栏目');
}
$check = $pdo->prepare(
'SELECT id FROM categories WHERE id = :id'
);
$check->execute([':id' => $parentId]);
if (!$check->fetch()) {
throw new InvalidArgumentException('父栏目不存在');
}
}
if ($id === null) {
$stmt = $pdo->prepare(
'INSERT INTO categories
(parent_id, name, slug, sort_order, is_active)
VALUES
(:parent_id, :name, :slug, :sort_order, :is_active)'
);
} else {
$stmt = $pdo->prepare(
'UPDATE categories
SET parent_id = :parent_id,
name = :name,
slug = :slug,
sort_order = :sort_order,
is_active = :is_active
WHERE id = :id'
);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
}
$stmt->bindValue(':parent_id', $parentId, $parentId === null
? PDO::PARAM_NULL : PDO::PARAM_INT);
$stmt->bindValue(':name', $name);
$stmt->bindValue(':slug', $slug);
$stmt->bindValue(':sort_order', $sortOrder, PDO::PARAM_INT);
$stmt->bindValue(':is_active', $active ? 1 : 0, PDO::PARAM_INT);
$stmt->execute();
return $id ?? (int) $pdo->lastInsertId();
}
上例的 120 个字符限制是应用自行设定的业务规则,不是 PDO 或 SQLite 的通用上限。生产项目应让表单提示、服务端验证和数据库字段设计保持一致;涉及多语言文本时,mb_strlen() 按字符数计算,需确保 PHP 启用了 mbstring。
更新栏目时还必须防止形成循环层级。上面的代码只检查“不能把自己设为父级”,但若栏目支持多层嵌套,还要继续检查:所选父栏目是否为当前栏目的后代。这个检查可以遍历祖先关系,或用项目采用的树形数据结构实现。遗漏该检查可能让导航树递归时无限循环。
栏目列表的查询排序可以固定为可靠的字段:
<?php
$stmt = $pdo->query(
'SELECT id, parent_id, name, slug, sort_order, is_active
FROM categories
ORDER BY sort_order ASC, id ASC'
);
$categories = $stmt->fetchAll();
渲染名称时要转义,例如 echo e($category['name']);。排序值应在服务端验证为整数;不要把表单传入的 ORDER BY 片段直接拼进 SQL。即使页面下拉框只展示有限选项,请求也可以被手工构造。
五、文章保存与发布状态
文章保存的核心检查包括:标题非空、栏目存在且可用、状态属于允许值、发布时间符合业务规则。参数化查询解决的是 SQL 值的安全绑定问题,不会自动验证这些业务条件。
<?php
function saveArticle(PDO $pdo, array $data, ?int $id = null): int
{
$title = trim((string)($data['title'] ?? ''));
$slug = trim((string)($data['slug'] ?? ''));
$summary = trim((string)($data['summary'] ?? ''));
$body = (string)($data['body'] ?? '');
$categoryId = filter_var(
$data['category_id'] ?? null,
FILTER_VALIDATE_INT
);
$status = (string)($data['status'] ?? 'draft');
if ($title === '' || mb_strlen($title) > 200) {
throw new InvalidArgumentException('标题不能为空且不能超过 200 个字符');
}
if ($categoryId === false || $categoryId === null) {
throw new InvalidArgumentException('请选择有效栏目');
}
if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) {
throw new InvalidArgumentException('文章 slug 格式不正确');
}
if (!in_array($status, ['draft', 'published'], true)) {
throw new InvalidArgumentException('文章状态不正确');
}
$category = $pdo->prepare(
'SELECT id FROM categories
WHERE id = :id AND is_active = 1'
);
$category->execute([':id' => $categoryId]);
if (!$category->fetch()) {
throw new InvalidArgumentException('栏目不存在或已停用');
}
$publishedAt = null;
if ($status === 'published') {
$publishedAt = trim((string)($data['published_at'] ?? ''));
if ($publishedAt === '') {
$publishedAt = gmdate('Y-m-d H:i:s');
} else {
$date = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i:s',
$publishedAt,
new DateTimeZone('UTC')
);
if (!$date || $date->format('Y-m-d H:i:s') !== $publishedAt) {
throw new InvalidArgumentException('发布时间格式应为 YYYY-MM-DD HH:MM:SS');
}
}
}
if ($id === null) {
$stmt = $pdo->prepare(
'INSERT INTO articles
(category_id, title, slug, summary, body, status,
published_at, updated_at)
VALUES
(:category_id, :title, :slug, :summary, :body, :status,
:published_at, CURRENT_TIMESTAMP)'
);
} else {
$stmt = $pdo->prepare(
'UPDATE articles
SET category_id = :category_id,
title = :title,
slug = :slug,
summary = :summary,
body = :body,
status = :status,
published_at = :published_at,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND deleted_at IS NULL'
);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
}
$stmt->bindValue(':category_id', $categoryId, PDO::PARAM_INT);
$stmt->bindValue(':title', $title);
$stmt->bindValue(':slug', $slug);
$stmt->bindValue(':summary', $summary);
$stmt->bindValue(':body', $body);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':published_at', $publishedAt,
$publishedAt === null ? PDO::PARAM_NULL : PDO::PARAM_STR);
$stmt->execute();
return $id ?? (int) $pdo->lastInsertId();
}
示例将发布时间按 UTC 存储。若项目按本地时区存储,应统一数据库、应用和前台的时区策略;不能一部分时间按服务器本地时区写入,另一部分按 UTC 读取。存储 UTC 通常便于跨时区处理,展示时再转换为目标时区。
SQLite 对部分参数类型的处理和 MySQL 不完全相同,项目接入时应针对实际数据库测试。还要处理数据库唯一约束冲突,例如 slug 重复。不要只在表单里检查是否重复,因为两个并发请求仍可能同时通过检查;数据库唯一约束才是最终保障。遇到冲突后,应捕获数据库异常并返回清晰的用户提示。
六、筛选、分页与前台可见条件
文章后台通常需要按关键词、栏目和状态筛选。查询条件应使用绑定参数;分页参数则应在服务端转换并限制范围。下面的示例限制每页最多 100 条,这是应用层规则,不是数据库对所有项目的通用要求。
<?php
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = min(100, max(1, (int)($_GET['per_page'] ?? 20)));
$offset = ($page - 1) * $perPage;
$sql = 'SELECT a.id, a.title, a.status, a.published_at,
c.name AS category_name
FROM articles a
JOIN categories c ON c.id = a.category_id
WHERE a.deleted_at IS NULL';
$params = [];
if (isset($_GET['category_id']) && $_GET['category_id'] !== '') {
$categoryId = filter_var($_GET['category_id'], FILTER_VALIDATE_INT);
if ($categoryId !== false) {
$sql .= ' AND a.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
}
if (isset($_GET['status']) && in_array(
$_GET['status'],
['draft', 'published'],
true
)) {
$sql .= ' AND a.status = :status';
$params[':status'] = $_GET['status'];
}
$sql .= ' ORDER BY a.updated_at DESC, a.id DESC
LIMIT :limit OFFSET :offset';
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$articles = $stmt->fetchAll();
前台文章查询不能只按 status = 'published' 判断,还应排除软删除文章,并处理未来发布时间。若“定时发布”意味着在指定时间前不公开,查询条件应包含 published_at <= 当前时间;后台编辑列表则通常需要看见草稿和未来发布时间的文章。前台与后台应使用不同的查询条件,避免草稿意外公开。
如果数据库驱动不支持绑定 LIMIT、OFFSET 的方式与示例相同,可以先把值强制转换为整数,再通过驱动兼容的方式传入;不要直接拼接未经验证的原始请求字符串。
七、删除策略与栏目变更
文章删除建议使用 POST 请求,并检查登录权限、CSRF 令牌和文章 ID。软删除示例如下:
<?php
function softDeleteArticle(PDO $pdo, int $id): void
{
$stmt = $pdo->prepare(
'UPDATE articles
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND deleted_at IS NULL'
);
$stmt->execute([':id' => $id]);
}
软删除不等于永久备份:它只是在数据库中保留记录,仍需安排数据库备份和恢复演练。还应明确恢复流程、保留期限,以及是否需要记录操作人和删除原因。若项目有审计要求,应增加操作日志,而不是仅靠 updated_at 推断谁做了什么。
删除栏目时,若栏目仍有关联文章,示例策略是拒绝删除,要求管理员先移动或处理文章:
<?php
function deleteCategory(PDO $pdo, int $id): void
{
$count = $pdo->prepare(
'SELECT COUNT(*) FROM articles
WHERE category_id = :id AND deleted_at IS NULL'
);
$count->execute([':id' => $id]);
if ((int)$count->fetchColumn() > 0) {
throw new RuntimeException('栏目下仍有文章,不能删除');
}
$stmt = $pdo->prepare(
'DELETE FROM categories WHERE id = :id'
);
$stmt->execute([':id' => $id]);
}
真实项目若存在并发写入,仅先查文章数量再删除并非完整的并发控制。可以在事务中执行检查和删除,并依赖数据库外键限制;数据库和隔离级别不同,具体并发行为也不同。较稳妥的业务方案通常是禁用栏目、禁止新文章归入,再由管理员迁移存量文章,确认后才删除。
栏目停用和栏目删除不是一回事。停用栏目通常保留历史文章及其 URL,但不再出现在新建文章的可选列表中;是否继续在前台展示该栏目下已发布内容,应作为单独的产品规则定义。不要简单地在前台隐藏栏目后就认定所有关联文章都安全处理了。
八、表单安全与请求处理
后台表单的创建、修改、删除都应使用 POST 或其他明确的写操作请求,并校验 CSRF 令牌。令牌应由服务器生成,保存在会话中,提交时使用安全比较函数验证。以下是简化示例,接入时还需确保会话已安全启动:
<?php
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verifyCsrf(?string $token): void
{
$known = $_SESSION['csrf_token'] ?? '';
if ($token === null || $known === '' ||
!hash_equals($known, $token)) {
http_response_code(400);
exit('Invalid CSRF token');
}
}
表单中加入隐藏字段,服务端在处理写请求时调用 verifyCsrf():
<form method="post" action="/admin/articles/save">
<input type="hidden" name="csrf_token"
value="<?= e(csrfToken()) ?>">
<input name="title" required maxlength="200">
<button type="submit">保存</button>
</form>
CSRF 防护不能代替身份认证、权限校验或输入验证。它主要用于降低已登录用户被诱导从其他站点发起状态变更请求的风险。对删除和发布等重要操作,还可以增加二次确认或更高权限要求,但确认弹窗本身不是安全措施。
文章正文若用富文本编辑器,编辑器输出可能包含 HTML。对正文进行 HTML 清洗时,应明确允许的标签、属性、链接协议和嵌入内容;阻止脚本标签并不代表所有 XSS 风险都已解决。对于普通文本标题和摘要,按输出位置转义通常更合适。
九、把示例接入现有源码
- 定位入口:找到后台路由、控制器、模型和模板,确认项目已有的认证、CSRF、数据库连接和异常处理机制。
- 核对字段:逐一对照现有表结构与前台查询,优先复用已有字段,避免同一含义出现多个不同字段名。
- 先做迁移:在测试库执行结构变更,导入一份脱敏的真实数据,检查旧文章和栏目能否正常读取。
- 改写入逻辑:把参数化 SQL 和业务校验放入现有服务或模型层,不要只在模板中做校验。
- 改前台读取:分别检查栏目导航、文章列表、详情页、搜索、分页和缓存,保证草稿、停用栏目和已删除文章按规则处理。
- 清理缓存:如果栏目树或文章列表使用缓存,保存、移动、发布、删除后应使相关缓存失效。
不要将示例中的 SQLite 初始化方式直接粘贴到一个已经使用 MySQL 的框架中,也不要在控制器里另建一套绕过现有模型的写库流程。框架项目通常已有路由、中间件、ORM 和迁移约定,遵循这些约定更容易保证事务、权限与测试一致。
十、常见错误与排查顺序
- 只改后台表单:数据能保存,但前台仍读取旧字段。检查所有查询、模板、API 和缓存键。
- 删除栏目后文章失联:事先没有决定删除策略。优先采用禁止删除、迁移文章或停用栏目,并明确关联规则。
- 只靠前端校验:绕过浏览器即可提交任意状态或栏目 ID。服务端必须重新验证所有关键参数。
- 把参数化查询当作万能防护:它帮助避免 SQL 注入,但不解决越权、XSS、CSRF、重复 slug 和错误业务状态。
- 动态拼接排序字段:排序列名不能像普通值一样绑定。使用服务端白名单,例如只允许
created_at或title,再生成固定 SQL 片段。 - 删除按钮使用 GET:爬虫、预加载或误点都可能触发写操作。删除应走经过权限与 CSRF 校验的写请求。
- 以为软删除能替代备份:数据库损坏、误更新或账户被盗仍会影响数据。定期备份并实际验证恢复。
- 只测正常输入:还要测试空标题、非法 ID、重复 slug、停用栏目、未来发布时间、越权请求和并发提交。
排查保存失败时,可按顺序看表单字段名是否与服务端一致、请求方法是否正确、服务端验证是否通过、SQL 参数是否绑定、数据库约束是否触发。生产环境不要把数据库错误和堆栈直接显示给访客;应记录必要日志,并向用户返回可理解但不泄露内部信息的提示。
十一、上线前检查清单
- 数据库已备份,迁移可以在测试环境重复执行或有明确回滚方案。
- 栏目与文章的必填项、唯一规则、层级和删除策略已经确定。
- 新增、编辑、发布、停用、删除与恢复均经过服务端权限检查。
- 所有用户输入都按用途验证;所有 HTML 输出都按上下文转义或经过可信清洗。
- 写操作使用 POST 等适当方法,并实施 CSRF 校验。
- 前台不会公开草稿、未来发布文章或已软删除内容。
- 栏目树、详情 URL、站点地图和缓存与新字段保持一致。
- 已测试重复提交、非法状态、失效栏目、重复 slug 和数据库错误。
新闻网站的内容管理并不只是“能保存就算完成”。可靠的改造应先明确栏目与文章的业务关系,再调整数据结构、服务端验证和读写路径,并把权限、安全、迁移与前台兼容一起纳入检查。以上示例适合作为实现思路;落地时应以现有源码的框架、数据库和内容规则为准,不要把示例里的字段长度、分页上限或删除策略误认为所有项目都必须采用的固定标准。




