用 PHP 搭建新闻网站源码:文章发布与分类功能实现

AI资讯16小时前发布 jdkjadmin
0 00
用 PHP 搭建新闻网站源码:文章发布与分类功能实现

用 PHP 搭建新闻网站源码:文章发布与分类功能实现

新闻网站的基础功能通常包括文章录入、分类管理、文章列表和详情展示。它们看起来只是几张表和几个表单,实际开发时却涉及数据完整性、权限控制、HTML 转义、请求伪造防护等问题。只依赖前端校验或把表单数据直接写入数据库,容易造成数据错误或安全漏洞。

本文用 PHP 8.1 及以上版本、PDO 和 MySQL 8.0 演示一个精简但可运行的实现。示例将文章内容按纯文本保存,展示时转义 HTML;发布接口要求登录,并使用 CSRF 令牌保护。它适合小型内容站点的功能起步,不包含完整的后台账号管理、富文本编辑器、审核工作流和文件上传。

一、功能范围与数据流

本文实现的功能包括:

  • 通过数据库保存新闻分类和文章。
  • 管理员登录后创建文章,选择分类并设置草稿或发布状态。
  • 公众只能看到已发布文章,且只能看到仍然存在的分类。
  • 文章标题、摘要和内容都按普通文本处理,输出时进行 HTML 转义。

基本数据流是:浏览器提交表单,PHP 检查登录状态、CSRF 令牌和字段规则,再通过 PDO 预处理语句写入数据库;读取文章时,页面只查询允许公开的数据,并在输出前转义文本。

请注意,示例中的登录检查只依赖会话变量 $_SESSION['user_id']。实际部署前必须接入可靠的登录系统,并确保该变量只能由服务端认证流程设置。隐藏表单、前端按钮或“只有管理员知道的 URL”都不能代替权限校验。

二、创建数据表

先创建数据库和两张表。分类名称设为唯一值;文章通过 category_id 引用分类。外键使用 RESTRICT,意味着分类仍被文章使用时,数据库会拒绝删除该分类,避免文章变成没有有效分类的数据。

CREATE DATABASE news_demo
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE news_demo;

CREATE TABLE categories (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name VARCHAR(80) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_categories_name (name)
) ENGINE=InnoDB;

CREATE TABLE articles (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    category_id BIGINT UNSIGNED NOT NULL,
    title VARCHAR(200) NOT NULL,
    summary VARCHAR(500) NOT NULL DEFAULT '',
    content MEDIUMTEXT NOT NULL,
    status ENUM('draft', 'published') NOT NULL DEFAULT 'draft',
    published_at DATETIME NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_articles_public_list (status, published_at, id),
    KEY idx_articles_category (category_id),
    CONSTRAINT fk_articles_category
        FOREIGN KEY (category_id) REFERENCES categories(id)
        ON DELETE RESTRICT
) ENGINE=InnoDB;

VARCHAR(200)、VARCHAR(500) 和 MEDIUMTEXT 是数据库字段类型和容量边界,不代表 PHP 已经完成输入校验。PHP 仍需拒绝超出业务规则的值。MySQL 的字符串长度通常按字符处理,但不同字符集、排序规则和存储类型有各自的限制;不要把字段容量当成前端提示的唯一依据。

示例未使用 CHECK 约束来限制文章状态,因为部署环境的数据库版本和约束执行行为需要明确核实。这里通过 PHP 白名单限制状态,并使用 ENUM 约束状态取值。生产项目也可以将状态改为数值或独立状态表,但应确保数据库和应用的允许值保持一致。

三、连接数据库与公共函数

建立 config.php。数据库账号密码应来自环境变量或不纳入版本控制的配置文件,不能提交到公开代码仓库。PDO 设置异常模式,数据库连接使用预处理语句;预处理语句有助于避免 SQL 注入,但不会自动转义输出 HTML,也不会代替业务校验。

<?php
declare(strict_types=1);

session_start();

$dsn = 'mysql:host=127.0.0.1;dbname=news_demo;charset=utf8mb4';
$dbUser = getenv('NEWS_DB_USER') ?: 'news_app';
$dbPass = getenv('NEWS_DB_PASS') ?: '';

$pdo = new PDO($dsn, $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

再建立 functions.php。htmlspecialchars 用于在 HTML 文本上下文中编码特殊字符,降低用户输入被浏览器解释为标签或脚本的风险。它不是对所有输出场景通用的编码函数:若把数据放到 JavaScript、CSS 或 URL 中,需要针对对应上下文处理。本文只把数据输出到 HTML 文本节点和已正确构造的属性位置。

<?php
declare(strict_types=1);

function e(?string $value): string
{
    return htmlspecialchars(
        $value ?? '',
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );
}

function requireLogin(): void
{
    if (empty($_SESSION['user_id'])) {
        http_response_code(403);
        exit('请先登录后再操作。');
    }
}

function csrfToken(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }

    return $_SESSION['csrf_token'];
}

function verifyCsrfToken(?string $token): bool
{
    return is_string($token)
        && isset($_SESSION['csrf_token'])
        && hash_equals($_SESSION['csrf_token'], $token);
}

CSRF 令牌用于确认表单请求来自当前会话中生成的页面,帮助防范攻击者诱使已登录用户提交请求。它不能证明用户有权限,也不能防止 XSS。涉及状态变更的操作应使用 POST,并同时进行身份和权限检查。会话 Cookie 还应在 HTTPS 环境中设置 Secure、HttpOnly 和合适的 SameSite 属性。

四、准备分类数据

分类管理可以单独做一个后台页面。为了让发布流程可运行,先插入几条分类:

INSERT INTO categories (name) VALUES
('国内'),
('国际'),
('科技');

正式的分类新增接口也必须要求登录和相应的管理权限,并使用预处理语句。分类名称要做长度、空值和重复检查;数据库的唯一索引是防止并发请求写入重复名称的重要兜底。用户看到的重复名称错误应友好处理,不要直接展示数据库异常或连接信息。

五、制作文章发布表单

创建 article_create.php。分类列表由数据库读取,不应相信浏览器传来的分类名称。表单提交分类 ID,服务端稍后仍会重新确认该 ID 对应的分类存在。

<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';

requireLogin();

$categories = $pdo->query(
    'SELECT id, name FROM categories ORDER BY name'
)->fetchAll();

if (!$categories) {
    exit('请先创建至少一个分类。');
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>发布文章</title>
</head>
<body>
  <h1>发布文章</h1>

  <form method="post" action="article_store.php">
    <input type="hidden" name="csrf_token"
           value="<?= e(csrfToken()) ?>">

    <label>标题
      <input type="text" name="title" maxlength="200" required>
    </label>

    <label>分类
      <select name="category_id" required>
        <option value="">请选择</option>
        <?php foreach ($categories as $category): ?>
          <option value="<?= (int) $category['id'] ?>">
            <?= e($category['name']) ?>
          </option>
        <?php endforeach; ?>
      </select>
    </label>

    <label>摘要
      <textarea name="summary" maxlength="500"></textarea>
    </label>

    <label>正文
      <textarea name="content" rows="14" required></textarea>
    </label>

    <label>状态
      <select name="status">
        <option value="draft">保存为草稿</option>
        <option value="published">立即发布</option>
      </select>
    </label>

    <button type="submit">保存文章</button>
  </form>
</body>
</html>

HTML 的 maxlength 和 required 可以改善填写体验,但可以被用户绕过,不能代替服务端校验。若表单开放给不可信用户,还要考虑请求体大小限制、速率限制和滥用防护。正文较大时,应在 Web 服务器和 PHP 配置中设置合理的请求大小上限;具体数值取决于部署需求,不存在适合所有站点的固定值。

六、校验并保存文章

创建 article_store.php。服务端先确认请求方法和登录状态,再校验 CSRF 令牌、标题、分类 ID、摘要、正文和状态。分类 ID 是客户端传来的值,因此必须检查格式并查询数据库确认分类存在。SQL 中的表名和列名是固定的,只有数据值使用占位符;参数占位符不能用来绑定列名或排序方向。

<?php
declare(strict_types=1);

require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';

requireLogin();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit('仅支持 POST 请求。');
}

if (!verifyCsrfToken($_POST['csrf_token'] ?? null)) {
    http_response_code(400);
    exit('表单已失效,请返回页面重新提交。');
}

$title = trim((string) ($_POST['title'] ?? ''));
$summary = trim((string) ($_POST['summary'] ?? ''));
$content = trim((string) ($_POST['content'] ?? ''));
$categoryRaw = $_POST['category_id'] ?? '';
$status = $_POST['status'] ?? '';

if ($title === '' || mb_strlen($title, 'UTF-8') > 200) {
    http_response_code(422);
    exit('标题不能为空,且不能超过 200 个字符。');
}

if (mb_strlen($summary, 'UTF-8') > 500) {
    http_response_code(422);
    exit('摘要不能超过 500 个字符。');
}

if ($content === '') {
    http_response_code(422);
    exit('正文不能为空。');
}

if (!is_string($categoryRaw) || !ctype_digit($categoryRaw)) {
    http_response_code(422);
    exit('分类参数无效。');
}

$categoryId = (int) $categoryRaw;
if ($categoryId < 1) {
    http_response_code(422);
    exit('分类参数无效。');
}

$allowedStatuses = ['draft', 'published'];
if (!is_string($status) || !in_array($status, $allowedStatuses, true)) {
    http_response_code(422);
    exit('文章状态无效。');
}

$checkCategory = $pdo->prepare(
    'SELECT id FROM categories WHERE id = :id'
);
$checkCategory->execute(['id' => $categoryId]);

if (!$checkCategory->fetch()) {
    http_response_code(422);
    exit('所选分类不存在。');
}

$publishedAt = $status === 'published' ? date('Y-m-d H:i:s') : null;

$insert = $pdo->prepare(
    'INSERT INTO articles
        (category_id, title, summary, content, status, published_at)
     VALUES
        (:category_id, :title, :summary, :content, :status, :published_at)'
);

$insert->execute([
    'category_id' => $categoryId,
    'title' => $title,
    'summary' => $summary,
    'content' => $content,
    'status' => $status,
    'published_at' => $publishedAt,
]);

header('Location: article_list.php', true, 303);
exit;

mb_strlen 按字符数检查标题和摘要,需要 PHP 的 mbstring 扩展。部署时应确认扩展已启用;如果没有启用,代码会因函数不存在而失败。不能简单用 strlen 替代,因为 UTF-8 中文字符通常占多个字节,strlen 统计的是字节数。

这里对文章正文没有设置应用层字符数上限,数据库字段为 MEDIUMTEXT,但实际可接收的数据还受 PHP、Web 服务器、数据库和网络配置影响。若业务需要正文上限,应明确单位是字符还是字节,并在服务端校验,同时将限制与数据库容量、请求大小配置协调起来。

保存发布时间时,示例使用 PHP 服务器当前时间。生产环境应统一 PHP 和数据库时区,通常采用 UTC 存储、展示时再转换为站点时区。若文章从草稿改为发布、从发布改回草稿,发布时间的更新规则也应由业务明确定义;本示例只处理首次创建。

七、显示公开文章列表

创建 article_list.php。列表只查询已发布文章,并通过内连接确保关联分类仍存在。文章标题、摘要和分类名输出前调用 e()。分页参数应限制为正整数,并在 SQL 中采用整数形式;以下示例暂不分页,数据量增长后不应无限制读取全站文章。

<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';

$stmt = $pdo->query(
    'SELECT a.id, a.title, a.summary, a.published_at,
            c.name AS category_name
     FROM articles AS a
     INNER JOIN categories AS c ON c.id = a.category_id
     WHERE a.status = \'published\'
       AND a.published_at IS NOT NULL
     ORDER BY a.published_at DESC, a.id DESC
     LIMIT 50'
);

$articles = $stmt->fetchAll();
?>
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>新闻列表</title>
</head>
<body>
  <h1>新闻列表</h1>

  <?php if (!$articles): ?>
    <p>暂时没有已发布的文章。</p>
  <?php else: ?>
    <ul>
      <?php foreach ($articles as $article): ?>
        <li>
          <a href="article.php?id=<?= (int) $article['id'] ?>">
            <?= e($article['title']) ?>
          </a>
          <span>分类:<?= e($article['category_name']) ?></span>
          <p><?= e($article['summary']) ?></p>
          <time><?= e($article['published_at']) ?></time>
        </li>
      <?php endforeach; ?>
    </ul>
  <?php endif; ?>
</body>
</html>

LIMIT 50 是示例中的固定上限,不是数据库或 PHP 的通用限制。正式分页可以使用 LIMIT :limit OFFSET :offset,并将经过严格范围校验的整数绑定为整数类型,或者在验证后转成整数再拼入 SQL。不可直接把未经校验的请求参数拼接到查询语句中。

八、显示文章详情

创建 article.php。查询条件同时限制文章 ID 和发布状态,避免公众通过修改 URL 读取草稿。ID 虽然是数字,也不能因为“看起来不像 SQL”就直接拼接进 SQL。

<?php
require __DIR__ . '/config.php';
require __DIR__ . '/functions.php';

$idRaw = $_GET['id'] ?? '';

if (!is_string($idRaw) || !ctype_digit($idRaw)) {
    http_response_code(404);
    exit('文章不存在。');
}

$id = (int) $idRaw;
if ($id < 1) {
    http_response_code(404);
    exit('文章不存在。');
}

$stmt = $pdo->prepare(
    'SELECT a.title, a.summary, a.content, a.published_at,
            c.name AS category_name
     FROM articles AS a
     INNER JOIN categories AS c ON c.id = a.category_id
     WHERE a.id = :id
       AND a.status = \'published\'
       AND a.published_at IS NOT NULL
     LIMIT 1'
);
$stmt->execute(['id' => $id]);
$article = $stmt->fetch();

if (!$article) {
    http_response_code(404);
    exit('文章不存在。');
}
?>
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title><?= e($article['title']) ?></title>
</head>
<body>
  <article>
    <p>分类:<?= e($article['category_name']) ?></p>
    <h1><?= e($article['title']) ?></h1>
    <time><?= e($article['published_at']) ?></time>
    <p><?= e($article['summary']) ?></p>
    <div><?= nl2br(e($article['content']), false) ?></div>
  </article>
</body>
</html>

nl2br 将换行符转换成 HTML 换行标签。这里先调用 e() 再换行,因此正文中的尖括号仍会作为文本显示,而不会成为可执行 HTML。这适用于纯文本编辑体验。若要支持富文本,不能直接移除转义;应使用经过维护的 HTML 白名单净化器,并明确允许的标签、属性和 URL 协议。

九、分类筛选的实现思路

分类列表页通常先查询有效分类,再按分类 ID 查询文章。查询文章时要同时限制状态和分类 ID,避免草稿泄露。示例查询如下:

<?php
$categoryId = (int) $validatedCategoryId;

$stmt = $pdo->prepare(
    'SELECT a.id, a.title, a.summary, a.published_at,
            c.name AS category_name
     FROM articles AS a
     INNER JOIN categories AS c ON c.id = a.category_id
     WHERE a.category_id = :category_id
       AND a.status = \'published\'
       AND a.published_at IS NOT NULL
     ORDER BY a.published_at DESC, a.id DESC
     LIMIT 50'
);

$stmt->execute(['category_id' => $categoryId]);
$articles = $stmt->fetchAll();

$validatedCategoryId 不能直接来自未检查的请求值;应像详情页一样检查它是字符串数字、转换后大于零,并确认分类存在。即便查询使用了预处理语句,输入仍需要验证,因为参数化解决的是 SQL 数据边界问题,不负责业务规则、授权或错误提示。

十、常见错误与关键边界

  • 只做前端校验。浏览器校验可以被绕过。标题长度、状态白名单、分类存在性和登录权限都必须在服务端验证。
  • 把用户数据直接拼进 SQL。预处理语句应覆盖所有外部数据值。动态表名或排序字段不能作为普通参数绑定,必须从服务端固定白名单中选择。
  • 误以为预处理语句能防 XSS。数据库写入和页面输出是不同安全边界。标题、分类名、摘要和正文在输出到 HTML 时都需要按上下文处理。
  • 直接把正文当富文本输出。未经净化的 HTML 可能包含脚本或危险属性。本文采用纯文本展示;富文本需要专门的白名单净化流程。
  • 只检查文章是否存在,不检查是否公开。详情查询必须带上发布状态条件。后台预览草稿应使用单独的、受权限保护的路由。
  • 仅依赖分类下拉框。客户端提交的分类 ID 可被随意修改。服务端要验证分类存在且用户有权使用该分类,数据库外键则提供最后一道数据完整性保障。
  • 分类删除没有明确策略。本文用外键拒绝删除仍有关联文章的分类。若业务希望删除分类,应先要求管理员迁移文章,或设计明确的归档策略,不应悄悄留下无效关联。
  • 重复提交导致重复文章。发布接口可增加幂等令牌或业务去重机制;浏览器成功提交后使用 303 跳转,可避免刷新时重复提交表单,但不能单独解决所有重复请求问题。
  • 把异常详情返回给公众。数据库连接错误和 SQL 异常应记录到受控日志,生产页面只返回通用错误信息。日志中也要避免记录密码、会话令牌等敏感内容。
  • 忽略时区和分页。时间需要统一管理;文章量增大后应分页并检查查询计划。索引是否合适要结合真实查询和数据规模验证,不能仅凭字段名称推断性能。

十一、部署前检查

上线前至少检查以下事项:

  1. PHP 已启用 PDO MySQL 驱动和 mbstring,版本满足代码使用的语法要求。
  2. 数据库使用 utf8mb4,应用账号仅拥有所需的读写权限,不使用数据库管理员账号连接网站。
  3. 后台发布接口要求登录,并在每个请求中检查实际权限;需要时区分编辑、发布和分类管理权限。
  4. 全站启用 HTTPS,合理设置会话 Cookie 属性,并在登录后更新会话 ID,降低会话固定风险。
  5. 生产环境关闭错误详情显示,配置日志、备份和恢复流程,并测试分类外键与数据迁移行为。
  6. 对列表实现分页,对公开接口考虑访问频率限制;对后台提交限制请求大小,避免异常大请求消耗资源。

至此,文章从后台表单进入数据库,再通过公开列表和详情页展示的基础链路已经完整。这个结构将数据校验、权限判断、数据库访问和输出编码分别处理,便于后续添加分页、文章编辑、分类筛选和审核流程。扩展功能时,应继续遵守同一原则:请求参数不可信,写操作要授权,SQL 使用参数化查询,输出根据目标上下文进行编码。

© 版权声明

相关文章