每年到了毕业设计选题的时候,总有一批同学在"做什么题目"上卡住。太简单的怕没工作量,太复杂的怕做不完,纯前端又怕技术含量不够。如果你的方向是web开发,又对nodejs这个技术栈有兴趣,摄影分享网站其实是一个非常值得考虑的选题——业务模型清晰、技术点覆盖面广、演示效果好,而且踩坑记录特别多,网上参考资料充足。
这篇文章就围绕"nodejs摄影分享网站"这个毕设项目,把我从环境配置到部署上线的完整过程写出来。重点讲几个大家高频遇到的问题:nodejs安装及环境配置、npm脚本无法执行、图片上传处理、视频播放、以及怎么把端口号藏起来。文章面向刚接触nodejs的毕设选手,也适合想快速搭一个完整全栈作品的初学者。内容都是实操经验,拿过去就能照着做。
1. 选题逻辑与技术栈选型:一个稳妥又能讲明白的毕设方案
1.1 摄影分享网站这个选题的天然优势
先说选题。为什么摄影分享网站适合做毕设?核心在于它"看着简单,但深度足够"。
从业务模型看,它天然包含用户体系(注册、登录、个人主页)、作品体系(上传、展示、分类)、互动体系(点赞、收藏、评论)三条主线。这三条线随便展开一条,都能做出可观的工作量,而且彼此之间还有数据关联——比如"我收藏了哪些作品""这个摄影师的作品被谁点赞最多",这些查询语句写出来,数据库设计的含金量一下就体现出来了。
从展示效果看,它的页面天然好看。摄影作品以图片为主,瀑布流排布视觉冲击力强,答辩时演示效果比做一套后台管理系统好太多。评委看屏幕的时候,第一眼的观感往往会直接影响对你工作量的判断。
还有一个很现实的原因:技术参考资源极其丰富。nodejs生态里,Express框架的教程、multer处理上传的方案、JWT鉴权的示例,一搜一大把。真遇到卡点,Stack Overflow上基本都有答案。对毕设这种需要控制时间成本的项目来说,这一点太重要了。
1.2 技术选型的取舍:为什么是Express + MySQL + 服务端渲染
技术选型这件事,我见过不少同学一上来就选最火的框架,结果把自己坑了。毕设的核心原则是:在能讲清楚原理的前提下,选择生态最稳、资料最多的方案。
| 技术点 | 我的选择 | 备选方案 | 为什么这样选 |
|---|---|---|---|
| 运行时 | Node.js LTS | — | 毕设标配,环境好配 |
| Web框架 | Express 4.x | Koa、NestJS | Express中间件模型直观,教程最多,调试简单 |
| 数据库 | MySQL 8.x | MongoDB | 关系型表结构更适合"用户-作品-评论"这类关联查询,答辩时也更能讲出设计逻辑 |
| 前端 | EJS模板 + jQuery | Vue/React前后端分离 | 服务端渲染把逻辑集中在Node层,工作量聚焦且容易演示 |
| 上传处理 | Multer + Sharp | 原生fs | Multer成熟稳定,Sharp做压缩和缩略图很顺手 |
| 会话方案 | JWT | Session + Cookie | 无状态,接口调试方便,不用考虑分布式会话 |
这里重点说说为什么我推荐服务端渲染而不是前后端分离。
前后端分离确实更贴近企业实践,但对毕设来说有个实际问题:你需要同时维护Node接口层和前端工程,接口返回数据、前端渲染页面,任何一环出问题,调试链路都会拉长一倍。而用EJS模板做服务端渲染,数据在Node层直接拼进HTML返回,整个页面生命周期都能在一个请求里追踪完。
答辩时老师问"你这个页面数据是从哪来的",你一条链路讲到底:浏览器请求路由,路由调数据库查询,数据渲染进模板——逻辑非常清晰。而前后端分离的话,你要解释"前端调接口、接口调数据库、再跨域返回",中间还多了异步状态管理,反而容易讲乱。
2. 环境搭建第一课:Node.js安装方法与npm脚本执行策略的坑
2.1 版本选择与安装路径:一个关于权限的细节
Node.js安装本身不复杂,但有两个细节值得单独说。
第一,版本一定要选LTS(长期支持版)。不要图新鲜去装最新的Current版本,毕设项目用的是Express这类依赖npm生态库的框架,LTS版本下所有依赖的兼容性都经过充分验证。我当时的做法是直接去nodejs官网下载Windows安装包,一路默认安装。
第二,安装路径。默认路径是C:\Program Files\nodejs,这个路径本身没问题,但有个隐患:Program Files目录受Windows用户账户控制(UAC)保护,后续你在全局安装包、修改npm配置时,偶尔会遇到权限相关的报错。更稳妥的做法是装到D:\nodejs这种纯用户目录下。
不过说实话,权限问题还不是最常见的坑,最常见的是下面这个。
2.2 npm.ps1无法加载文件:执行策略问题的排查全过程
搜索"nodejs"相关的热词时,你会发现npm : 无法加载文件 c:\program files\nodejs\npm.ps1,因为在此系统上禁止运行脚本这条报错出现的频率高得惊人。我自己第一次配环境时也踩过,当时还以为是nodejs装坏了,实际上完全不相干。
这个报错的完整信息长这样:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.microsoft.com/fwlink/?LinkID=135170。 + CategoryInfo : SecurityError, 未找到具有名称 npm 的已识别 cmdlet根因是:npm命令在PowerShell里实际执行的是npm.ps1这个PowerShell脚本,而Windows PowerShell默认的执行策略是Restricted(禁止运行任何脚本)。你之前手动双击装过的那些软件虽然能跑,但它们都是exe程序,不受这个策略限制。npm是脚本,就中招了。
排查方式很简单,在PowerShell里执行:
Get-ExecutionPolicy -List输出里会看到当前生效的策略,如果某个作用域的ExecutionPolicy是Restricted,就印证了问题判断。
解决办法有三种,我按推荐度排序。
方法一:修改当前用户的执行策略为RemoteSigned(推荐)
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned的意思是:本地创建的脚本可以运行,从互联网下载的脚本必须经过签名。npm.ps1是nodejs安装时生成的本地脚本,可以直接运行。设置完后关掉终端重开,再执行npm -v就正常了。
方法二:用cmd代替PowerShell
如果你不想改动系统策略,最简单粗暴的方式是打开CMD(命令提示符)来跑npm命令。CMD执行的是npm.cmd,不经过PowerShell的执行策略,天然不会触发这个报错。
方法三:换用nvm-windows管理Node版本
nvm-windows是Node版本管理工具,好处是安装多版本自由切换,而且它在安装Node时会自动处理一些环境变量问题。不过它的执行原理其实还是走PowerShell,所以装完nvm后如果遇到同样的脚本报错,依然需要执行策略配合。
这里提醒一句:最终你交项目时,npm start脚本也是通过命令行跑的。如果答辩演示现场用的是学校电脑,第一次跑项目报出这个错,场面会很难看。所以环境配好后一定要用命令行完整跑一遍npm install && npm start,当场验证整个链路是通的。
2.3 环境验证:把第一个Express服务跑起来
配完环境后别急着装各种包,先验证一下基础链路:
node -v npm -v然后创建一个目录,初始化项目并安装Express:
mkdir photoshare cd photoshare npm init -y npm install express新建app.js:
const express = require('express'); const app = express(); app.get('/', (req, res) => { res.send('摄影分享网站开发中'); }); app.listen(3000, () => { console.log('服务已启动: http://localhost:3000'); });运行node app.js,浏览器打开localhost:3000,能看到页面就说明环境彻底通了。这一步的成功标准是:你在纯命令行环境下完成了从初始化到运行的全过程,后面所有踩坑都绕不开这条路。
3. 数据库设计与项目骨架:先想清楚表结构再写代码
3.1 四张核心表的设计思路与字段说明
摄影分享网站的表结构不需要很复杂,但设计时要想清楚三件事:数据怎么存、数据怎么查、数据怎么保证一致性。我当时设计了四张核心表:用户表、作品表、评论表、收藏表。点赞因为是需要频繁更新的操作,单独设计了关联表。
用户表(users):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED AUTO_INCREMENT | 主键 |
| username | VARCHAR(50) UNIQUE | 用户名 |
| password_hash | VARCHAR(100) | bcrypt加密后的密码 |
| avatar | VARCHAR(255) | 头像路径 |
| bio | VARCHAR(200) | 个人简介 |
| created_at | DATETIME | 注册时间 |
作品表(works):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED AUTO_INCREMENT | 主键 |
| user_id | INT UNSIGNED | 上传者ID |
| title | VARCHAR(100) | 作品标题 |
| description | TEXT | 作品描述 |
| image_url | VARCHAR(255) | 原图路径 |
| thumb_url | VARCHAR(255) | 压缩缩略图路径 |
| likes_count | INT DEFAULT 0 | 点赞数,冗余字段 |
| created_at | DATETIME | 上传时间 |
评论表(comments):id、work_id、user_id、content、created_at。
收藏表(favorites):id、user_id、work_id、created_at,加上UNIQUE(user_id, work_id)联合唯一约束,防止重复收藏。
点赞表(likes):结构和收藏表类似,同样加联合唯一约束。
这里有两个设计心得值得展开说。
第一个是计数冗余字段。likes_count存在作品表里,每次点赞时用事务同时更新这张表和likes表。为什么不用COUNT(*)实时统计?因为首页瀑布流每页拉20个作品,如果每个作品都实时统计点赞数,就是20次子查询,数据库压力会成倍增加。冗余一个计数,查询时直接取,代价只是点赞操作多一条UPDATE语句,非常划算。
第二个是图片的双路径设计。原图单独存一张,缩略图存另一张。首页瀑布流加载缩略图(几KB到几十KB),点击查看详情时再加载原图。这个设计在用户图片量大时体验差距极其明显,也是答辩时能讲出来的亮点——很多没经验的同学只存一张图,首页卡成幻灯片。
3.2 Express项目目录结构:分层是给自己的未来留余地
项目不能全堆在app.js里,否则写到后期这个文件会膨胀到上千行。我采用的是经典的分层结构:
photoshare/ ├── app.js # 入口文件,初始化Express应用 ├── config/ │ └── db.js # 数据库连接池配置 ├── routes/ # 路由层,只做路径分发 │ ├── auth.js │ ├── works.js │ └── comments.js ├── controllers/ # 控制器层,处理业务逻辑 │ ├── authController.js │ ├── worksController.js │ └── commentsController.js ├── middlewares/ # 中间件 │ ├── auth.js # JWT校验 │ └── errorHandler.js # 全局错误处理 ├── utils/ # 工具函数 ├── views/ # EJS模板 │ ├── index.ejs │ ├── login.ejs │ ├── register.ejs │ └── work.ejs ├── public/ # 静态资源 │ ├── css/ │ ├── js/ │ └── uploads/ # 用户上传图片目录 └── .env # 环境变量(不入库)分层的目的不是显得专业,而是为了让每条请求的职责边界清晰。路由层只负责"这个URL对应哪个函数",控制器层只负责"拿到参数、调数据库、返回结果",模板层只管"把数据渲染成HTML"。出Bug时你能快速定位是哪个环节的问题,而不是在300行的路由文件里来回翻。
3.3 数据库连接池:并发场景下不被冲垮的关键
项目一开始为了省事,可以直接mysql.createConnection连一次库跑一次请求。但当有十几个同学同时访问你的网站时,频繁创建和销毁连接会拖慢响应时间,甚至报Too many connections错误。
正确做法是使用连接池。我当时的config/db.js是这样写的:
const mysql = require('mysql2/promise'); require('dotenv').config(); const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0 }); module.exports = pool;连接池的思路可以参考"餐厅的固定服务员数量":服务员数量固定(connectionLimit),来的客人排队等空位(queueLimit),而不是每次来客人都临时招一个新服务员。这样数据库连接数始终可控,不会被打满。
说个配套技巧:数据库密码、密钥这些一定要放在.env文件里,通过dotenv加载。答辩时老师问"你的数据库密码写在哪",你回答"在环境变量里,代码库只保留占位符",这比把密码明文写在config.js里加分不止一个档次。
4. 核心功能从0到1:注册登录、图片上传与互动闭环
4.1 注册登录与JWT鉴权:密码绝不能明文存储
用户模块几乎是所有网站的地基。地基要稳,关键在两点:密码安全与登录状态管理。
密码存储必须用哈希,不只是MD5或SHA1那种"可逆向"的哈希,而是带盐的慢哈希算法。我这里用的是bcryptjs:
const bcrypt = require('bcryptjs'); // 注册:加密存储 const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); // 登录:比较验证 const isValid = await bcrypt.compare(password, user.password_hash);saltRounds = 10意味着计算哈希要迭代2^10次,单次大约50-100毫秒。这个"慢"恰恰是安全的体现——攻击者想暴力破解,每试一个密码都这么慢,成本直接拉满。
登录状态的方案我选了JWT:
const jwt = require('jsonwebtoken'); // 签发 const token = jwt.sign({ userId: user.id, username: user.username }, process.env.SECRET_KEY, { expiresIn: '7d' }); // 校验中间件 function auth(req, res, next) { const token = (req.headers.authorization || '').split(' ')[1]; if (!token) return res.status(401).json({ message: '未登录' }); jwt.verify(token, process.env.SECRET_KEY, (err, decoded) => { if (err) return res.status(401).json({ message: '登录已过期' }); req.userId = decoded.userId; next(); }); }为什么用JWT而不用Session?关键原因是无状态。Session需要服务端存一份会话记录,而JWT把用户信息直接签在token里,服务端只需要在请求过来时验签,不需要查库。对毕设这种单机部署的项目来说简单干净,页码刷新、多设备登录也都天然兼容。
4.2 图片上传:multer的完整配置与文件校验
图片上传是摄影网站的核心路径,这条路径上最容易出问题的是三个点:文件大小控制、类型校验、重命名防冲突。
我用的Multer配置如下:
const multer = require('multer'); const path = require('path'); const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, path.join(__dirname, '../public/uploads')); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`); } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const ext = path.extname(file.originalname).toLowerCase(); if (allowed.includes(ext)) cb(null, true); else cb(new Error('仅支持图片文件')); } });关于文件名,一定是时间戳+随机数+扩展名的格式。原因很现实:如果直接用用户原始文件名,两个不同用户上传了同名的photo.jpg,后一个就会覆盖前一个。这是每届毕设必踩的坑之一。
文件大小限制在5MB,对应的是服务器吞吐能力。如果你的服务器是低配学生机,图片体量太大会直接把带宽吃满。
上传后还应该做一步压缩生成缩略图。用Sharp很轻松:
const sharp = require('sharp'); await sharp(originalPath) .resize(400, 400, { fit: 'cover' }) .jpeg({ quality: 80 }) .toFile(thumbPath);这一步的收益是立竿见影的:首页瀑布流加载的是几百KB的缩略图,而不是几MB的原图,页面速度能快一个数量级。
4.3 首页作品流与作品详情页:分页查询和关联数据
首页的作品流本质就是一条分页查询。当时的SQL是这样:
const [rows] = await pool.query( `SELECT w.id, w.title, w.thumb_url, w.likes_count, u.username, u.avatar FROM works w JOIN users u ON w.user_id = u.id ORDER BY w.created_at DESC LIMIT ? OFFSET ?`, [pageSize, offset] );JOIN联表是这里的关键词。作品列表需要展示作者头像和昵称,与其拿回user_id再一条条查用户表(N+1问题),不如一次性JOIN出来。这个思路在答辩时也很容易讲:一条SQL把两张表的字段合并返回,避免循环查询。
详情页需要的数据更多:作品大图、完整描述、点赞数、评论列表、作者信息。我的做法是先查作品和作者,再查评论列表,分成两条查询而不是一条大联查。原因是评论列表是动态增长的集合数据,和作品本身的单行数据性质不同,混在一条SQL里反而会让查询变得复杂。
4.4 点赞、收藏、评论:用事务保证数据不错乱
互动模块看起来简单,实际上有一个一致性问题:作品表里的likes_count是冗余的,它必须和likes表里的记录始终同步。如果用户点了取消点赞,likes表删了一条,但作品表的likes_count忘了减一,数据就永远错了。
解决方式是数据库事务:
const conn = await pool.getConnection(); try { await conn.beginTransaction(); await conn.query( 'INSERT INTO likes (user_id, work_id) VALUES (?, ?)', [userId, workId] ); await conn.query( 'UPDATE works SET likes_count = likes_count + 1 WHERE id = ?', [workId] ); await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }事务的语义是"要么全做,要么全不做"。插入点赞记录和更新计数这两步被绑定成一个原子操作,任何一步失败都会回滚,数据库不会出现一边有数据一边没数据的尴尬。
评论的设计则简单很多:评论表一条记录代表一条评论,详情页按时间倒序查询展示即可。如果后续想拓展,可以加楼中楼回复,给评论表增加一个parent_id字段就行,这是后话。
5. 答辩前必做的性能与安全优化
5.1 图片懒加载与流式读取大文件
网站做完功能只是"能用",答辩时演示的流畅度才真正影响评价。一个摄影网站如果打开首页,20张大图同时加载,低配电脑直接卡死,印象分会掉不少。
懒加载是最有效的第一招。现代浏览器原生支持,给img标签加loading="lazy"属性即可:
<img src="<%= work.thumb_url %>" loading="lazy" alt="<%= work.title %>">浏览器会在图片即将进入视口时才开始加载,首屏只加载顶部少数几张图,滚动时再逐步加载下方内容。
第二招是流式读取媒体文件。直接res.sendFile()把整个文件读进内存再返回,遇到大视频内存直接吃紧。正确的做法是用fs.createReadStream边读边发,同时处理浏览器的Range请求:
const fs = require('fs'); const path = require('path'); app.get('/media/:filename', (req, res) => { const filePath = path.join(__dirname, '../public/uploads', req.params.filename); const stat = fs.statSync(filePath); const fileSize = stat.size; const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, '').split('-'); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Content-Type': 'video/mp4' }); fs.createReadStream(filePath, { start, end }).pipe(res); } else { res.writeHead(200, { 'Content-Length': fileSize, 'Content-Type': 'video/mp4' }); fs.createReadStream(filePath).pipe(res); } });Range请求的意义在于支持拖动进度条。没有它,视频只能从头到尾顺序播放;有了它,用户拖到哪个位置,服务器就从哪个字节开始发送数据,这是"播放视频"功能流畅体验的根基。前端用<video>标签直接指向这个接口即可。
5.2 防SQL注入与XSS攻击:基础但必须做
很多毕设做完了功能,却感觉自己"什么都没学到"——很大程度上是因为该有的安全防护没做,代码里全是漏洞还浑然不觉。
SQL注入的防御非常简单:永远不要拼接SQL字符串。我见过有同学这么写:
// 反例:千万不要这么写 const query = `SELECT * FROM users WHERE username = '${username}'`;如果username是' OR '1'='1,这句SQL就变成了恒真条件,攻击者不需要密码就能登录。正确做法是参数化查询:
// 正确做法 const [rows] = await pool.query( 'SELECT * FROM users WHERE username = ?', [username] );参数化查询的本质是数据和SQL语句分离,由数据库驱动负责转义用户输入,攻击者注入的片段只会被当作普通字符串处理。
XSS方面,EJS模板默认会自动对输出内容做HTML转义,这是它的优势。只要不在模板里用<%- %>输出未处理的用户输入,基本就安全了。另外可以加一个helmet中间件,它会给响应头加上一系列安全相关的字段,一行代码就能配置:
const helmet = require('helmet'); app.use(helmet());5.3 日志与全局错误处理:让崩溃可追踪
程序不可能不出错,重要的是出错时你能知道发生了什么。我建议用morgan记录访问日志:
const morgan = require('morgan'); app.use(morgan('combined'));然后加一个全局错误处理中间件,放在所有路由之后:
app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ message: '服务器内部错误' }); });这两个配置加起来不到十行代码,但效果是:程序崩溃时你能从控制台看到完整调用栈,知道是哪个接口、在哪个文件第几行出的错。对调试的帮助比任何调试工具都大。
6. 部署上线与端口号隐藏:Nginx反向代理实战
6.1 用PM2管理Node进程:不怕终端关掉服务就挂
本地跑node app.js服务正常,但一关终端服务就停了。把项目部署到云服务器上,不能靠手动一直开着终端。
PM2是Node.js进程管理器,用法极简:
npm install -g pm2 pm2 start app.js --name photoshare pm2 save pm2 startuppm2 save保存当前进程列表,pm2 startup生成开机自启脚本。这样服务器重启后服务会自己拉起来,不用你手动连上去启动。完整的运维链路就通了。
6.2 通过Nginx把3000端口藏起来:反向代理配置详解
这是很多人关心的"nodejs怎样隐藏端口号"问题。项目默认跑在3000端口,用户访问你得输入http://服务器IP:3000,又丑又难记,而且直接把Node进程暴露在公网上,等于告诉别人"我的应用就在这里"。
标准解法是用Nginx做反向代理:Nginx监听80端口,接收用户请求后转发给本机的3000端口,用户全程只会看到http://服务器IP,完全感知不到后端端口的存在。
核心配置如下:
server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads/ { alias /project/photoshare/public/uploads/; expires 7d; add_header Cache-Control "public"; } }这里有两个要点。
第一,proxy_pass http://127.0.0.1:3000指向本机Node服务。Nginx相当于一个"前台接待",用户敲80端口的门,前台把需求转达给后厨的3000端口,再把结果端回来。用户看到的始终是前台(80端口),后厨地址永远不会暴露。
第二,location /uploads/直接让Nginx处理图片静态资源,不经过Node进程。图片文件读磁盘的活儿,Nginx比Node高效得多,还能加expires 7d让浏览器缓存图片一周,减少重复请求。
配置完成后,执行nginx -t检查语法,再nginx -s reload重载,访问http://你的域名或IP就是你的网站了。
6.3 域名、HTTPS与额外加分项
如果手头有多余的钱,可以花几十块买个域名,再配置HTTPS证书。这一步不是必须的,但属于低成本高回报的加分项。
HTTPS可以用Let's Encrypt免费证书,配置也不难。有了HTTPS后,网站地址栏会出现小锁标志,答辩时展示这个细节,说明你有完整的线上环境意识,而不只是本地跑通。
另外说一个我在部署过程中遇到的小坑:云服务器的安全组规则默认只开放了22、80、443等端口,如果你在本地测试时用3000端口直连,需要单独放行3000端口。等Nginx配好之后,记得把3000端口从安全组里关掉——既然已经通过Nginx隐藏了端口,就没必要再对外暴露了。
最后分享一点个人体会。整个项目做下来,我最大的收获反而不是nodejs语法或者Express的API,而是"遇到问题怎么系统排查"的能力。比如那次npm脚本执行策略的报错,百度上一堆人让重装nodejs,实际上改一行策略就解决。做毕设的过程里,每解决一个这种不起眼的坑,你对整套技术栈的理解就会深一层。摄影分享网站这个选题恰好覆盖面够广、深度够足,从环境配置到数据库设计再到线上部署,每一环都有真问题要解决,做完以后你会发现自己是真的会写一个完整网站了,而不只是会跑别人写好的例子。