☰
PHP+SQL+HTML新闻管理系统源码实战:从环境搭建到安全优化
2026/10/1 1:29:25 网站建设 项目流程

简介:这份资源是基于PHP、SQL与HTML构建的简单新闻管理系统源码,面向Web开发初学者及需要快速搭建小型网站新闻模块的开发者。它提供新闻的创建、编辑、删除与查看等基础功能,帮助读者理解后端逻辑、数据库存储与前端展示之间的交互流程。压缩包共2378个文件,约25.54MB,以2273个svg图标资源为主,另含32个php脚本、8个css样式表、4个js脚本、2个html页面及1个sql建库文件,覆盖页面结构、样式美化与数据操作等环节。目前已有564人学习下载。通过研读这套源码,读者可掌握数据库连接配置、新闻增删改查、列表分页、关键词搜索与时间倒序排列等实现思路,并熟悉前后端协作与项目部署的基本流程,适合作为课程设计或练手项目的参考素材。

1. 新闻管理系统源码拆解:从 PHP+SQL+HTML 到能跑起来的第一版

很多人拿到「基于php+sql+html的简单的新闻管理系统源码.zip」这类压缩包,第一反应是解压、丢进网站根目录、改数据库配置,然后刷新浏览器。结果要么白屏,要么报Access denied for user 'root'@'localhost',要么首页出来了但点进详情页 404。这套技术栈看着简单——PHP 负责逻辑、SQL 负责存储、HTML 负责展示,三层分得清清楚楚,但真正让它跑起来,卡人的从来不是代码本身,而是环境、路径、编码和数据库这四件事。

这篇笔记面向两类人:一是刚学完 PHP 基础语法、想找个完整项目练手的新手,二是需要快速交付一个内部信息发布页、不想上重型框架的开发者。我会按「这套源码的骨架长什么样 → 数据库怎么建 → 后端接口怎么改 → 前端页面怎么接 → 出问题怎么查」的顺序,把每一步落到可复制的命令和参数上。你不需要先看完所有代码,跟着走一遍,本地就能跑出一个能发新闻、能看列表、能进详情的版本。中间会穿插我踩过的坑,比如中文乱码、SQL 注入、分页算错,这些在「简单系统」里反而最容易翻车。

2. 先看清骨架:PHP+SQL+HTML 三层怎么分工

2.1 目录结构与请求流向

拿到源码包,先别急着改配置。用tree或文件管理器把目录展开,典型的简单新闻管理系统长这样:

news/ ├── index.php # 前台首页,新闻列表 ├── detail.php # 新闻详情页 ├── admin/ │ ├── login.php # 后台登录 │ ├── news_add.php # 添加新闻 │ ├── news_list.php # 新闻管理列表 │ └── logout.php ├── config/ │ └── db.php # 数据库连接配置 ├── includes/ │ └── functions.php # 公共函数,如分页、转义 ├── static/ │ ├── css/style.css │ └── images/ └── sql/ └── news.sql # 建表语句

请求流向是:浏览器访问index.php→ PHP 引入config/db.php连接数据库 → 执行 SQL 查询新闻列表 → 把结果集用while循环拼进 HTML 模板 → 输出给浏览器。详情页多一步:从 URL 取id参数,查单条记录。后台则是先session_start()判断登录态,再执行增删改。

这里的关键认知是:PHP 文件既是控制器又是视图。简单系统不会上 MVC,index.php里上半段是 PHP 查询逻辑,下半段是 HTML 输出。你改样式就动 HTML 部分,改数据就动 PHP 部分,别混在一起改。

2.2 数据库表设计:三张表就够

打开sql/news.sql,常见建表语句如下。如果源码里没有,按这个建:

CREATE DATABASE IF NOT EXISTS news_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE news_db; -- 新闻分类表 CREATE TABLE category ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT '分类名' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 新闻主表 CREATE TABLE news ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL COMMENT '标题', content TEXT NOT NULL COMMENT '正文', category_id INT UNSIGNED NOT NULL DEFAULT 1, author VARCHAR(50) DEFAULT 'admin', create_time INT UNSIGNED NOT NULL COMMENT '时间戳', views INT UNSIGNED DEFAULT 0 COMMENT '浏览量', KEY idx_category (category_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 管理员表 CREATE TABLE admin ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password CHAR(32) NOT NULL COMMENT 'MD5值' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO admin (username, password) VALUES ('admin', MD5('admin123')); INSERT INTO category (name) VALUES ('公司动态'), ('行业资讯'), ('产品更新');

参数说明:create_time用INT存时间戳而不是DATETIME,是因为简单系统里 PHP 用time()写入、用date()格式化输出最顺手,也避免时区配置问题。content用TEXT而不是VARCHAR,新闻正文可能几千字,VARCHAR(2000)容易截断。password存 MD5 是这类老源码的常见做法,但你要知道 MD5 已经不安全,后面第 5 章会讲怎么换成password_hash()。

注意:建库时字符集必须用utf8mb4,不要用utf8。utf8在 MySQL 里是残缺的,存 emoji 或某些生僻字会报Incorrect string value,这是中文乱码最常见的根因之一。

2.3 连接配置:改对这四个参数

config/db.php通常长这样:

<?php $host = '127.0.0.1'; $user = 'root'; $pass = 'your_password'; $db = 'news_db'; $conn = mysqli_connect($host, $user, $pass, $db); if (!$conn) { die('连接失败: ' . mysqli_connect_error()); } mysqli_set_charset($conn, 'utf8mb4');

四个参数对应你的实际环境:$host本地一般127.0.0.1,用localhost在某些系统上会走 socket 导致连接慢;$user和$pass是你 MySQL 的账号;$db必须和建库名一致。最后一行mysqli_set_charset千万别删,它决定 PHP 和 MySQL 之间传输的字符集,删了就会出现「数据库里是好的,页面上是问号」。

如果你用的是 PHP 7 以上,mysqli_connect报错会直接抛 Warning,建议改成异常捕获:

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); try { $conn = new mysqli($host, $user, $pass, $db); $conn->set_charset('utf8mb4'); } catch (Exception $e) { die('数据库连接异常,请检查 config/db.php'); }

这样出错时不会把数据库账号密码路径暴露给浏览器,只给一句人话提示,日志里再记详细错误。

3. 把后端跑通:查询、分页、写入的最小实现

3.1 新闻列表查询与分页参数

首页index.php的核心是「查列表 + 分页」。先看一个能用的查询:

<?php require 'config/db.php'; $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; $size = 10; $offset = ($page - 1) * $size; $sql = "SELECT id, title, author, create_time, views FROM news ORDER BY create_time DESC LIMIT ? OFFSET ?"; $stmt = $conn->prepare($sql); $stmt->bind_param('ii', $size, $offset); $stmt->execute(); $result = $stmt->get_result(); $newsList = []; while ($row = $result->fetch_assoc()) { $row['create_time_text'] = date('Y-m-d H:i', $row['create_time']); $newsList[] = $row; }

逻辑说明:intval($_GET['page'])把 URL 参数强制转成整数,max(1, ...)防止页码为 0 或负数导致OFFSET算错。LIMIT ? OFFSET ?用预处理占位符,bind_param('ii', ...)里两个i表示两个整数参数,这是防 SQL 注入的标准写法。date()把时间戳转成可读格式,放在 PHP 层做而不是 SQL 层做,是为了让 SQL 保持简单、走索引。

分页总数要单独查一次:

$countResult = $conn->query("SELECT COUNT(*) AS total FROM news"); $total = $countResult->fetch_assoc()['total']; $totalPages = ceil($total / $size);

参数怎么调:$size是每页条数,新闻列表一般 10 到 20 条,太多首屏加载慢,太少翻页频繁。ORDER BY create_time DESC依赖idx_create_time索引,如果你发现列表页慢,先EXPLAIN看这条 SQL 有没有走索引。

3.2 详情页取单条与浏览量自增

detail.php要做两件事:根据id查新闻、把views加一。

<?php require 'config/db.php'; $id = isset($_GET['id']) ? intval($_GET['id']) : 0; if ($id <= 0) { header('Location: index.php'); exit; } // 浏览量自增,用原子操作避免并发覆盖 $conn->query("UPDATE news SET views = views + 1 WHERE id = " . $id); $stmt = $conn->prepare("SELECT * FROM news WHERE id = ?"); $stmt->bind_param('i', $id); $stmt->execute(); $news = $stmt->get_result()->fetch_assoc(); if (!$news) { die('新闻不存在或已删除'); }

这里有个细节:UPDATE ... SET views = views + 1是数据库层面的原子自增,不要写成先SELECT views再UPDATE views = 旧值+1,后者在并发下会丢计数。$id虽然已经intval过,但UPDATE里我仍然直接拼接,因为整数转换后不存在注入风险;如果你不放心,同样可以用预处理。

3.3 后台写入:转义与字段校验

添加新闻的news_add.php,核心是接收表单、校验、插入:

<?php session_start(); if (!isset($_SESSION['admin_id'])) { header('Location: login.php'); exit; } require '../config/db.php'; $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? ''); $catId = intval($_POST['category_id'] ?? 1); $errors = []; if ($title === '' || mb_strlen($title) > 200) { $errors[] = '标题不能为空且不超过200字'; } if ($content === '') { $errors[] = '正文不能为空'; } if (empty($errors)) { $stmt = $conn->prepare( "INSERT INTO news (title, content, category_id, author, create_time) VALUES (?, ?, ?, ?, ?)" ); $author = $_SESSION['admin_name'] ?? 'admin'; $now = time(); $stmt->bind_param('ssisi', $title, $content, $catId, $author, $now); $stmt->execute(); header('Location: news_list.php'); exit; }

参数说明:bind_param('ssisi', ...)的类型串按顺序对应——s字符串标题、s字符串正文、i整数分类、s字符串作者、i整数时间戳。类型串写错是最隐蔽的 bug,比如把$catId写成s,MySQL 会做隐式转换,数据量小时看不出问题,数据量大了索引失效。mb_strlen而不是strlen,因为中文标题按字节算会超长。

注意:$_POST里的内容不要直接拼进 SQL,哪怕你觉得「后台只有自己人用」。SQL 注入不分前台后台,一个弱密码加上注入点,整库都能被拖走。

4. 前端页面怎么接:HTML 模板与数据循环

4.1 列表页的 HTML 骨架

PHP 查完数据,HTML 部分负责渲染。index.php下半段:

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>新闻中心</title> <link rel="stylesheet" href="static/css/style.css"> </head> <body> <div class="container"> <h1>新闻中心</h1> <ul class="news-list"> <?php foreach ($newsList as $item): ?> <li> <a href="detail.php?id=<?php echo $item['id']; ?>"> <?php echo htmlspecialchars($item['title']); ?> </a> <span class="meta"> <?php echo $item['author']; ?> · <?php echo $item['create_time_text']; ?> · 阅读 <?php echo $item['views']; ?> </span> </li> <?php endforeach; ?> </ul> <div class="pagination"> <?php for ($i = 1; $i <= $totalPages; $i++): ?> <a href="?page=<?php echo $i; ?>" class="<?php echo $i == $page ? 'active' : ''; ?>"> <?php echo $i; ?> </a> <?php endfor; ?> </div> </div> </body> </html>

关键点:htmlspecialchars($item['title'])必须加。新闻标题里如果有人写了<script>,不加这个函数就会直接执行,这就是存储型 XSS。<meta charset="utf-8">要和数据库字符集一致,否则中文显示成乱码。分页链接用?page=拼接,简单直接,不需要伪静态。

4.2 详情页输出与 nl2br

详情页正文输出:

<article class="news-detail"> <h1><?php echo htmlspecialchars($news['title']); ?></h1> <div class="meta"> 发布时间:<?php echo date('Y-m-d H:i', $news['create_time']); ?> </div> <div class="content"> <?php echo nl2br(htmlspecialchars($news['content'])); ?> </div> </article>

nl2br把正文里的换行符转成<br>,否则用户输入的多段文字会挤成一坨。顺序很重要:先htmlspecialchars转义,再nl2br加换行标签。反过来会把<br>也转义掉,页面上直接显示<br>文本。

4.3 后台表单与分类下拉

添加新闻的表单里,分类下拉要从数据库读:

<?php $cats = $conn->query("SELECT id, name FROM category ORDER BY id"); ?> <form method="post" action="news_add.php"> <input type="text" name="title" placeholder="新闻标题" required> <select name="category_id"> <?php while ($c = $cats->fetch_assoc()): ?> <option value="<?php echo $c['id']; ?>"> <?php echo htmlspecialchars($c['name']); ?> </option> <?php endwhile; ?> </select> <textarea name="content" rows="10" required></textarea> <button type="submit">发布</button> </form>

表单method="post"而不是get,因为正文可能很长,URL 长度有限制。required是浏览器原生校验,能挡掉空提交,但后端仍然要再校验一次,前端校验只是体验优化,不能当安全边界。

5. 避坑与排查:简单系统里最容易翻车的五件事

5.1 中文乱码:从数据库到浏览器的三层字符集

现象:数据库里用工具看是正常中文,页面上显示????或æ–°é—»。

原因:字符集在三个地方可能不一致——建库建表时的字符集、PHP 连接时的set_charset、HTML 的<meta charset>。任何一层用了utf8或gbk,就会断链。

解决:统一成utf8mb4。建库DEFAULT CHARSET utf8mb4,连接后$conn->set_charset('utf8mb4'),HTML 里<meta charset="utf-8">。已经存进去的乱码数据改不回来,只能重新录入。

5.2 SQL 注入:拼接字符串的代价

现象:新闻列表页 URL 加个?page=1' OR '1'='1,返回了全部数据甚至报错暴露表名。

原因:源码里用了"SELECT * FROM news WHERE id = $id"这种直接拼接,$id来自$_GET没过滤。

解决:所有来自用户的参数,要么intval强转,要么用prepare+bind_param。字符串参数一律走预处理,不要心存侥幸。老源码里addslashes不算合格防护,宽字节注入能绕过。

5.3 分页算错:最后一页多出空白

现象:总共 25 条,每页 10 条,分页显示 3 页,但第 3 页只有 5 条却渲染了 10 个空<li>。

原因:LIMIT的OFFSET算对了,但循环输出时没有判断$newsList是否为空,或者$totalPages用了floor而不是ceil。

解决:$totalPages = ceil($total / $size),ceil向上取整。循环前先if (empty($newsList))给个「暂无新闻」提示。$page超过$totalPages时重定向到最后一页,避免OFFSET超出返回空数组。

5.4 后台越权:没登录也能调接口

现象:直接访问admin/news_add.php,没登录也能提交表单,新闻被写入。

原因:news_add.php里只写了session_start(),但没判断$_SESSION['admin_id']是否存在。或者判断了,但login.php里 session 没正确写入。

解决:每个后台文件顶部统一加登录检查,最好抽成includes/auth.php,用require引入。检查逻辑是if (empty($_SESSION['admin_id'])) { header('Location: login.php'); exit; },exit不能省,否则后面的代码还会执行。

5.5 路径问题:相对路径在子目录里失效

现象:后台admin/login.php里require '../config/db.php'正常,但header('Location: index.php')跳转到了admin/index.php而不是根目录首页。

原因:header的Location是相对当前 URL 的,不是相对文件系统。在admin/下写index.php,浏览器会拼成admin/index.php。

解决:跳转根目录用header('Location: ../index.php'),或者定义常量define('BASE_URL', '/news/'),所有跳转用BASE_URL . 'index.php'。静态资源引用同理,static/css/style.css在子目录页面里要写成../static/css/style.css。

6. 进阶技巧:把 MD5 换成 password_hash 并加一层输出过滤

这套源码能跑起来之后,最该动的一处是密码存储。MD5 不加盐、计算快,彩虹表一查就出来。PHP 5.5 以后内置了password_hash()和password_verify(),改造成本极低。

注册或添加管理员时:

$plain = 'admin123'; $hash = password_hash($plain, PASSWORD_DEFAULT); // 存入数据库,字段长度要 >= 60,VARCHAR(255) 稳妥 $stmt = $conn->prepare("UPDATE admin SET password = ? WHERE username = ?"); $stmt->bind_param('ss', $hash, $username); $stmt->execute();

登录校验时:

$stmt = $conn->prepare("SELECT id, username, password FROM admin WHERE username = ?"); $stmt->bind_param('s', $username); $stmt->execute(); $admin = $stmt->get_result()->fetch_assoc(); if ($admin && password_verify($password, $admin['password'])) { session_start(); $_SESSION['admin_id'] = $admin['id']; $_SESSION['admin_name'] = $admin['username']; header('Location: news_list.php'); } else { $error = '用户名或密码错误'; }

注意admin表的password字段原来是CHAR(32)存 MD5,改成password_hash后输出是 60 字符以上,必须ALTER TABLE admin MODIFY password VARCHAR(255) NOT NULL。老数据没法自动升级,只能让管理员重置密码。

第二处值得加的是统一输出过滤函数,放在includes/functions.php:

function e($str) { return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8'); }

之后所有输出用<?php echo e($news['title']); ?>,比每次手写htmlspecialchars少打几个字,也避免漏写。ENT_QUOTES同时转义单双引号,防止属性值里的引号闭合逃逸。

验证改造是否生效,可以手动测:登录时故意输错密码,看是否返回统一错误提示而不是「密码错误」或「用户不存在」——后者会泄露用户名是否存在。再打开浏览器开发者工具,看登录请求的响应里有没有Set-Cookie带HttpOnly,没有的话在php.ini里设session.cookie_httponly = 1,能挡掉大部分 XSS 偷 cookie 的攻击。

我自己维护这类小系统有个习惯:每次改完数据库字段,先在测试库跑一遍ALTER,确认老数据不报错,再上生产。密码字段长度这种坑,我翻过一次车——线上改完VARCHAR(60),结果password_hash某次输出 61 字符,新管理员直接注册失败,查了半天才发现是字段长度卡在边界。所以字段宁可给宽,VARCHAR(255)不差那点空间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询