做这个“三国之家网站系统”的时候,其实想法很简单:身边几个喜欢三国文化的朋友一直吐槽,说网上的三国资料要么堆成一锅粥,要么就是营销号编的段子,想找个清爽一点、能按人物、战役、文章分类查阅的地方都难。加上当时正好在系统整理 SpringBoot + Vue 这套前后端分离的技术栈,就顺手把“做一个三国主题内容站”当成了练手项目。没想到做完之后被不少人问过源码和部署方式,索性把这套东西整理出来,从需求拆分到数据库设计,从前端组件到后端接口,再到最后的服务器部署,一条线讲清楚。
这篇文章适合几类人看:一是正在学 SpringBoot + Vue 前后端分离、手里缺一个完整项目的同学;二是想拿现成项目做二次开发、但卡在部署环节的开发者;三是对“一个内容型网站到底怎么从零搭起来”感兴趣的人。文里不会有花哨的架构,全是实际能跑通的方案和踩过的坑,照着操作就能把项目跑起来。
1. 需求先行:三国之家到底要做一个什么样的站点
很多新手拿到“做一个网站”这种需求,第一反应就是开代码,结果做着做着发现页面没规划、表结构乱改、前后端接口对不上。这个项目一开始我先花了几天把需求拆干净,后面写代码才顺。所谓“三国之家”,定位就是一个三国文化主题的内容展示与检索平台,重点解决信息组织问题,不做社交、不做电商、不做用户复杂体系,把核心功能做透比什么都强。
1.1 功能模块清单与优先级取舍
我把功能分成了三个梯队。第一梯队是必须做的基础盘:
- 人物模块:三国人物列表、人物详情(生平简介、所属阵营、活跃时期、主要事件);
- 战役模块:经典战役列表、战役详情(时间、对阵双方、兵力对比、结果、影响);
- 文章模块:围绕三国文化的图文资讯,支持按类别筛选和关键词搜索。
第二梯队是体验增强项:首页聚合展示(推荐人物、热门战役、最新文章)、站内全文搜索、浏览量统计。第三梯队属于延展内容:登录后台、评论互动、数据可视化大屏——这些我明确搁置了,不是做不了,而是对一个内容展示站来说,前期价值不高,反而会拖慢进度。
技术选型也按照“够用、稳定、好部署”的原则定了下来。后端就是 SpringBoot + MyBatis + MySQL,前端 Vue 全家桶加 Element Plus 组件库。这套组合没有特别新潮的东西,但胜在资料多、坑都被人踩过了,遇到问题很容易找到解决方案。数据库版本用的 MySQL 5.7 或 8.0 都行,只要字符集设置对,基本不会有兼容问题。
1.2 前后端分离的划分边界
这个项目的关键决策是“前后端彻底分离”。前端只负责渲染页面和交互,所有数据都通过 HTTP 接口从后端获取,前端不直接操作数据库,后端也不返回 HTML 页面。这样划分的好处是职责清晰:前端改样式不影响后端逻辑,后端加接口不影响前端页面,两边可以并行开发。
实际开发中,我给前端和后端分别建了工程目录,前端叫 web-front,后端叫 server-api,两个目录完全独立,各自维护依赖和配置文件。后端只在 application.yml 里配置允许跨域的来源地址,前端统一走 axios 请求后端接口,这样联调的时候用开发服务器代理,上线的时候用 Nginx 反向代理,切换成本很低。
1.3 数据库表结构设计
数据库设计我是先画 ER 图再落表,一共设计了 8 张表,核心的几张是三国人物表(tb_hero)、战役表(tb_battle)、文章表(tb_article)、分类表(tb_category)、友情链接表(tb_link),再加上一张站长配置表(tb_config)用来存站点标题、关键词、描述这些基础设置。
以三国人物表为例,核心字段大概是这样的:
CREATE TABLE `tb_hero` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', `name` varchar(50) NOT NULL COMMENT '人物姓名', `alias` varchar(100) DEFAULT NULL COMMENT '别名/字号', `camp` varchar(20) DEFAULT NULL COMMENT '所属阵营', `birth_year` varchar(20) DEFAULT NULL COMMENT '生卒年', `role` varchar(255) DEFAULT NULL COMMENT '主要官职/身份', `portrait` varchar(255) DEFAULT NULL COMMENT '头像地址', `description` text COMMENT '人物生平简介', `events` text COMMENT '主要事件', `view_count` int(11) DEFAULT '0' COMMENT '浏览量', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='三国人物表';这里有个设计细节值得说:人物和战役、文章之间其实存在关联关系,比如某场战役的参战人物。最正规的做法是建关联中间表,但考虑到内容站的浏览场景居多,前期为了降低维护成本,我选择了在“描述”字段里用文本方式维护关联信息。这么做牺牲了一点规范化,换来的是内容维护简单、代码查询简单,对一个小型内容站来说完全够用。
2. 后端骨架:SpringBoot 分层设计、MyBatis 映射与数据表落地
后端的骨架搭建是整个项目的地基。我的做法是先用 Spring Initializr 生成一个基础工程,然后按分层架构拆包。很多教程里喜欢把代码全部塞在几个类里,跑起来是没问题,但后面一加功能就乱。所以从第一天起,我就把包结构分成了 controller、service、mapper、entity、config 这几层,每一层的职责严格界定:Controller 只做参数接收和结果返回,Service 做业务逻辑,Mapper 做数据库操作,Entity 映射表结构。
2.1 工程结构与核心依赖
项目的核心依赖就这么几个,没有多余的:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>分页功能我直接用 PageHelper,这个插件和 SpringBoot + MyBatis 配合非常成熟,只需要在 service 层调用PageHelper.startPage(pageNum, pageSize),后面的查询就会自动带上分页,返回结构里也能拿到总记录数。有一点要提醒:PageHelper 只对紧随其后的第一条查询语句生效,如果你在 startPage 和查询之间插了其他数据库操作,分页就会错乱,这个坑我踩过一次。
2.2 统一返回结构与全局异常处理
前后端分离的项目里,接口返回格式如果不统一,前端解析起来会非常痛苦。我定义了一个 Result 类,所有接口一律返回这种结构:
{ "code": 200, "message": "操作成功", "data": { } }code 为 200 表示成功,非 200 表示业务异常,message 是给前端的提示文字,data 是真正的业务数据。这样前端在 axios 响应拦截器里只需要判断 code 是否为 200,不是就统一弹消息,不需要每个接口单独处理错误分支。
配合这个统一返回结构,我在 service 层加了一个自定义异常类 BusinessException,凡是业务上不允许的操作,比如查询对象不存在、参数校验失败,直接throw new BusinessException("xxx"),再配合一个全局异常处理器@RestControllerAdvice,把这些异常转换成统一的 Result 返回。这样做的好处是代码里不需要到处 try-catch,错误信息又不会丢失,前端拿到的永远是一个结构稳定的响应体。
2.3 MyBatis 映射的三种写法和我的选择
MyBatis 的 SQL 映射有三种主流写法:注解方式、XML 方式和 MyBatis-Plus 的方式。这个项目我没有引入 MyBatis-Plus,特意用的是原生 MyBatis 加 XML 文件,原因有两个:第一,很多教材和面试考核的还是原生 MyBatis 的 XML 写法,用这个项目练手能真正理解 resultMap、动态 SQL 这些概念;第二,复杂查询用 XML 写动态 SQL 比注解拼接字符串清晰太多。
一个典型的动态查询长这样:
<select id="selectHeroList" resultType="com.example.entity.Hero"> SELECT * FROM tb_hero <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="camp != null and camp != ''"> AND camp = #{camp} </if> </where> ORDER BY id DESC </select>这个写法的核心是<where>标签和<if>标签的配合,MyBatis 会自动处理多余的 AND 关键字,保证 SQL 拼接正确。另外在 application.yml 里必须开启驼峰映射,否则数据库的 create_time 字段无法自动映射到 Java 实体的 createTime 属性上:
mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml这个配置漏掉的话,你会发现查询结果里所有带下划线的字段都是 null,但控制台不报任何错,排查起来特别费时间。
3. 前端组装:Vue 组件化、axios 请求层与页面路由
前端这块我用的是 Vue 3 加 Vite 构建,UI 组件库选了 Element Plus。之所以不用 Vue CLI 的原因很简单:Vite 冷启动快、热更新快,开发体验完全不是一个档次。虽然 Vite 早期有些生态兼容问题,但在 2024 年这个时间点上已经非常稳定,直接上手没有任何问题。
3.1 前端工程初始化与目录约定
前端工程结构我按“页面 → 组件 → 请求 → 路由 → 状态”五个维度组织:
src/ api/ # 存放所有接口请求函数 assets/ # 静态资源 components/ # 公共组件 router/ # 路由配置 store/ # Pinia 状态管理 views/ # 页面级组件 utils/ # 工具函数 App.vue main.js这里要重点说下 api 目录。很多项目把所有请求直接写在页面里,一旦接口地址变了要全局搜索替换,极其痛苦。我的做法是先把 axios 实例封装在 utils/request.js 里,设置好 baseURL 和拦截器,然后在 api 目录下按业务模块建文件,比如 hero.js 里专门放人物模块的接口,battle.js 放战役模块的接口,每个接口函数只负责一件事。
// api/hero.js import request from '@/utils/request' export function getHeroList(params) { return request({ url: '/api/hero/list', method: 'get', params }) } export function getHeroDetail(id) { return request({ url: `/api/hero/${id}`, method: 'get' }) }这样页面组件里只需要import { getHeroList } from '@/api/hero',语义清晰,后期接口变动只需要改 api 文件里的一个地方。
3.2 核心页面组件设计与数据流
这个站点页面不多,但每个页面都有讲究。首页我拆成了四个区域:顶部导航栏(共用组件)、轮播图推荐位、热门人物列表、最新文章列表。这些区域一旦拆成独立组件,数据获取就变得很直接——每个组件在onMounted里调用自己的接口,拿到数据存到本地状态,渲染交给模板。
热门人物列表的核心代码是这样的逻辑:
<template> <div class="hero-grid"> <div class="hero-card" v-for="hero in heroList" :key="hero.id" @click="goDetail(hero.id)"> <el-image :src="hero.portrait" fit="cover" /> <div class="hero-name">{{ hero.name }}</div> <div class="hero-camp">{{ hero.camp }}</div> </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' import { getHotHeroList } from '@/api/hero' const router = useRouter() const heroList = ref([]) const loadData = async () => { const res = await getHotHeroList({ limit: 8 }) if (res.code === 200) { heroList.value = res.data } } onMounted(loadData) </script>列表页用的 Element Plus 的 el-pagination 分页组件,页码变化时重新请求接口。详情页则是根据路由参数:id调用详情接口,把返回的字段逐项渲染。这里我特意把详情页的加载状态处理了一下,请求期间显示骨架屏而不是空白页,这个小细节对用户体验的提升比想象中大得多。
3.3 路由设计与页面鉴权
路由配置上,我用的是 createWebHistory 模式,也就是 HTML5 History 模式。这个模式能让 URL 看着清爽,但有一个必须提前知道的坑:部署到服务器后,如果 Nginx 没配 try_files,用户刷新详情页就会 404。这个问题在第 5 章部署部分会专门讲,这里先埋个伏笔。
这个项目没有做登录系统,所以不需要复杂的前端路由守卫。但我还是加了一个简单的全局前置守卫,用来动态设置页面标题——根据路由的 meta.title 字段,把每个页面的标题改成“三国之家 - 人物列表”这种格式。这样做不仅对搜索引擎友好,也让浏览器标签页不会全部显示同一个站点名。
4. 联调排雷:跨域、日期格式、分页参数这些细节怎么统一
前后端分开开发的时候,最耗时的往往不是写功能,而是联调。前端说“我这边报跨域了”,后端说“我那边 Postman 测试没问题”,这种对话我在做这个项目时经历了太多次。后来我把联调阶段出现的几个高频问题集中整理了一下,提前约定好规范,后面就顺畅多了。
4.1 跨域问题:开发环境和生产环境要分开处理
跨域的根本原因是浏览器同源策略。前端跑在 localhost:5173,后端跑在 localhost:8080,端口不同就属于跨域。Postman 没有这个限制,所以后端觉得接口“没问题”,但浏览器就是拦截响应。
开发阶段的解决方案是前端在 Vite 配置文件里配一个代理,把 /api 前缀的请求转发到后端地址:
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样配置之后,前端请求/api/hero/list实际上会被转发到http://localhost:8080/api/hero/list,浏览器里看到的请求地址始终是同源的,跨域问题直接消失。
但生产环境不能依赖 Vite 代理,因为前端构建出来的是一堆静态文件,没有 Node 服务存在。生产环境的跨域方案有两个:一是后端开启 CORS,二是在 Nginx 上做反向代理。我推荐后者,因为 Nginx 反向代理不仅解决跨域,还能隐藏后端端口,配置也稳定。
4.2 时间格式统一:前端解析和后端返回必须对齐
时间字段是个隐性炸弹。Java 后端默认序列化 LocalDateTime 的格式是一长串 "2024-01-15T10:30:00",前端直接渲染出来非常丑,还需要额外处理。我的做法是在后端全局配置统一时间格式:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }这样所有接口返回的时间字段都是 "yyyy-MM-dd HH:mm:ss" 格式,前端拿到就能直接用,不需要写一堆格式化函数。这个配置属于“不做也行、做了省心一大片”的类型,强烈建议加上。
4.3 分页参数规范的约定
分页参数看起来简单,但前后端如果不提前约定好,容易出现“前端传 page=1,后端收 pageNum=1”这种对不上号的情况。这个项目的统一约定是:请求参数用 pageNum 和 pageSize,返回结构里包含 total 和 list 两个字段,list 是当前页数据,total 是总记录数。
我写了一个 PageResult 类来规范返回:
@Data public class PageResult<T> { private Long total; private List<T> list; }配合 PageHelper,service 层的查询逻辑是固定的模板:
public PageResult<Hero> getHeroList(int pageNum, int pageSize, String name, String camp) { PageHelper.startPage(pageNum, pageSize); List<Hero> heroes = heroMapper.selectHeroList(name, camp); PageInfo<Hero> pageInfo = new PageInfo<>(heroes); PageResult<Hero> result = new PageResult<>(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }这套分页模式我用了很多次,所有列表接口都长一个样,几乎没有出错过。前端只需要根据返回的 total 设置分页组件的总页数,配合 pageNum、pageSize 就能完整控制翻页。
5. 部署上线:前端构建、后端打包与服务端进程托管
部署这个环节,说实话是最容易劝退初学者的。本地跑得飞起,一到云服务器就各种幺蛾子:前端静态文件路径不对、后端端口没放开、数据库连不上、进程一关服务就没了。这一章我把操作步骤拆开,每一步都写清楚命令和验证方式,照着做基本不会翻车。
5.1 前端构建与 Nginx 配置
前端构建很简单,在 web-front 目录下执行npm run build,Vite 会生成一个 dist 目录,里面全是静态文件。把这个 dist 目录上传到服务器,放到 Nginx 的 html 目录或者自定义路径,然后修改 Nginx 配置。
这里就是之前提到的 History 路由 404 问题的解法。Nginx 默认配置下,访问/hero/3这个路径时,它会在服务器上找hero/3这个文件,找不到就返回 404。但我们的前端是单页应用,这个路径应该由 Vue Router 处理。所以 Nginx 需要配置 try_files:
server { listen 80; server_name your-domain.com; root /var/www/sanguozhijia/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }关键就是try_files $uri $uri/ /index.html;这一行。它的逻辑是:先尝试访问请求的路径,如果文件不存在,就回退到 index.html,交给 Vue Router 去处理路由。这样无论用户直接访问/hero/3还是从站内点击跳转,都能正确渲染页面。
location /api/块则是反向代理配置。浏览器请求/api/hero/list,Nginx 会把请求转发到http://127.0.0.1:8080/api/hero/list,后端服务接收请求并处理,响应再经过 Nginx 返回给浏览器。前后端的跨域问题在这一层就彻底解决了。
5.2 后端打包与数据库初始化
后端的打包是标准 Maven 流程。在 server-api 目录下执行:
mvn clean package -DskipTests执行完之后,target 目录下会生成一个 jar 包。上传到服务器之后,先用命令行方式启动一次,确认能起来再考虑后台托管:
java -jar sangouzhi-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod配置文件里我把环境拆成了 dev 和 prod 两套,application-prod.yml 里的数据库地址、账号密码都是线上配置,和本地开发环境隔离。这样做的好处是,打包时不用来回改配置,只需要启动时指定 profile 就行。
数据库初始化这一环很多人容易漏。服务器上的 MySQL 并不等于数据库已经建好了,你还需要手动创建数据库用户、导入表结构和初始化数据。我把这些做成了一个 SQL 脚本文件,包含建库语句、建表语句和基础数据插入语句。在服务器上执行一行命令搞定:
mysql -u root -p < sangouzhi_init.sql这里有一个细致但重要的点:脚本文件里如果包含中文字符,传输到服务器后执行前要把文件编码改成 UTF-8,否则导入后数据乱码。我习惯在导入前用file命令检查文件编码,再用--default-character-set=utf8mb4显式指定客户端字符集。
5.3 进程托管:用 systemd 让后端服务常驻
java -jar直接启动的问题是:一旦关闭终端窗口,服务就跟着终止。尤其用 nohup 启动的话,虽然不会立刻挂掉,但进程崩溃或者服务器重启之后不会自动恢复,还得手动操作。正规做法是注册一个 systemd 服务。
创建服务文件/etc/systemd/system/sanguozhi.service:
[Unit] Description=Sanguozhi Site Server After=network.target [Service] User=root WorkingDirectory=/opt/sanguozhi ExecStart=/usr/bin/java -jar /opt/sanguozhi/sanguozhi.jar --spring.profiles.active=prod Restart=on-failure RestartSec=10 [Install] WantedBy=multi-user.target然后依次执行:
systemctl daemon-reload systemctl start sanguozhi systemctl enable sanguozhienable是设置开机自启,这样服务器重启后服务会自动拉起。查看日志用journalctl -u sanguozhi -f,排查问题的时候特别方便。这套托管方式比 nohup 强在三个方面:崩溃自动重启、开机自启、日志集中管理。如果之前还在用nohup java -jar xxx.jar &这种土办法,强烈建议换成 systemd。
部署完成之后,通过systemctl status sanguozhi查看状态,再用curl访问一个接口验证:
curl http://127.0.0.1:8080/api/hero/list?pageNum=1&pageSize=5能看到 JSON 数据返回,说明后端正常;再在浏览器里访问域名,能看到页面完整渲染,说明前后端通道彻底打通——到这一步,整个“三国之家网站系统”就正式上线了。
最后分享一点个人体会。当年我刚学前后端分离的时候,最怕的不是写代码,而是“写完不知道下一步干嘛”。这个项目的价值在于它提供了一个完整的闭环:从需求分析到数据库设计,从接口开发到页面渲染,从本地联调到服务器部署,整条链路都走通了。你跟着做一遍下来,再回头去看那些零散的知识点,会发现它们之间的连接关系突然清晰了。如果你拿到源码之后想练手扩展,优先建议做两个方向:一个是给人物表加关联战役列表,另一个是接一个简单的后台登录。这两个功能做下来,你对“表关系设计”和“权限控制”的理解会再上一个台阶。