我一直认为像“基于 Spring Boot Vue 的家乡特色旅游宣传系统”这类题目是检验 Java 全栈能力很合适的练手项目。它逻辑不复杂但涉及的技术点足够全用户端展示、管理后台、数据库设计、文件上传、接口联调、服务器部署全部都要过一遍。这篇文章不是把源码贴一遍就完我会把需求怎么拆分、表结构怎么设计、核心代码怎么组织、部署文档怎么写这些关键点讲清楚还会穿插开发过程中真实踩过的坑。适合准备做毕业设计、想拿全栈项目找工作或者单纯想给家乡做个展示网站的同学。1. 需求拆解这个系统到底要给谁用、做哪些事1.1 三类使用角色的诉求很多人拿到这种题目第一反应就是“景点 新闻 留言”三张表然后开始写代码。实际做之前最好先把使用者捋一遍。这套系统里其实只有三类角色普通游客不登录打开网站就能浏览景点介绍、美食推荐、旅游路线看到感兴趣的内容可以留言互动。管理员登录后台后维护所有展示内容包括景点上架下架、轮播图替换、留言审核。系统维护者也就是开发者需要看日志、改配置、部署更新。角色定下来之后很多选择就顺理成章了。游客端不需要注册功能注册会拉高使用门槛对宣传型网站不划算。管理员端也不需要搞复杂的权限树一个管理员角色就够了Spring Security JWT 那一套可以砍掉一半配置直接用一个拦截器校验登录状态即可。1.2 功能清单怎么定才不会被需求方反复改这类系统的需求方往往不是一个专业产品经理他说不出“用户故事”和“验收标准”只能用“我要能改图片”“这个字能不能大一点”这种话表达。所以功能清单需要你来替他抽象成可落地的模块。我建议至少包含这几块模块功能点面向对象首页展示轮播图、精品景点推荐、当地美食速览游客景点模块景点列表、详情、位置、访问量游客美食与民俗图文介绍、分类筛选游客旅游路线路线推荐、天数、行程简介游客留言互动游客留言、后台审核、前台展示游客 / 管理员后台管理内容增删改查、上下架、排序、轮播图维护管理员登录鉴权管理员登录、注销、登录态保持管理员这个清单是有讲究的。首先每个模块都能在主页找到展示入口这样首页就不是一个死页面而是有真实内容支撑的聚合页。其次后台管理模块覆盖了所有前台数据来源避免出现“数据只能写死在代码里”的尴尬。提示如果需求方后续提出“加一个视频模块”“加一个预订功能”你不要直接拒绝而是先问一句“谁来维护内容、内容从哪来”。宣传类系统最怕功能堆砌维护成本会迅速盖过展示价值。2. 技术选型为什么用 Spring Boot Vue而不是简单静态页2.1 后端的选型逻辑后端用 Spring Boot 几乎是这个题目下的共识但选 Spring Boot 不是为了“流行”而是因为它能大幅压缩基础设施代码。一个空的 Spring Boot 项目自带内嵌 Tomcat、自动配置数据源、统一异常处理的能力配合 Lombok 可以省掉一堆 Getter/Setter。持久层我推荐 MyBatis-Plus而不是裸的 MyBatis 或 JPA。原因很直接这类系统的大部分操作是简单 CRUDMyBatis-Plus 的IService和Wrapper能直接生成单表查询、分页、逻辑删除的代码开发效率至少快三成。等真遇到复杂多表统计再手写 SQL 也来得及完全没必要一开始就陷入 XML 映射的繁琐中。数据库选 MySQL 8.0字符集必须用utf8mb4。这个细节经常被忽略但宣传系统里一定会出现生僻地名、民俗词、表情符号utf8mb4才能彻底避免乱码。2.2 前端选什么谁来写页面前端用 Vue 3 Vite Element Plus 是我目前比较推荐的标准组合。Vue 3 的 Composition APIscript setup语法比 Vue 2 的 Options API 更适合逻辑复用Element Plus 的后台表格、表单、弹窗组件开箱即用能保证后台页面“能用且不难看”。但我要说一句大实话如果团队里只有后端同学前后端分离项目最容易卡住的不是框架而是 CSS。一个很实际的解法是前台页面尽量用简单布局配合组件库不要自己从头写复杂样式。轮播图用 Element Plus 的el-carousel列表卡片用el-card栅格用el-row/el-col这样即使没有专业前端做出来的页面依然有完整感。2.3 前后端分离的接口约定既然选了前后端分离就要把接口约定提前定好。我习惯用一个统一的返回体ResultT避免每个接口返回结构不一致导致前端到处写分支判断。public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 200; result.message success; result.data data; return result; } public static T ResultT error(String message) { ResultT result new Result(); result.code 500; result.message message; return result; } }接口路径统一以/api开头例如/api/attractions/list、/api/messages/add。这样后期配置 Nginx 反向代理时只需要把/api/转发到后端其余路径全部走前端静态资源规则非常清晰。3. 数据库设计景点、栏目、留言三张核心表如何落地3.1 景点表核心业务表的字段取舍景点表是整个系统的地基。字段设计上有几个关键点封面图单独存一个字段区分于正文里的多图访问量字段可以先用visited_count简单累加不做独立的日志表sort字段用于手动排序避免前端拿到数据后还要按名称或时间猜顺序。CREATE TABLE t_attraction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 景点名称, cover VARCHAR(255) DEFAULT COMMENT 封面图, summary VARCHAR(500) DEFAULT COMMENT 一句话简介, content TEXT COMMENT 详细介绍, city VARCHAR(50) DEFAULT COMMENT 所属县市, visited_count INT DEFAULT 0 COMMENT 访问量, sort INT DEFAULT 0 COMMENT 排序权重, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, KEY idx_city (city), KEY idx_status_sort (status, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT景点表;这里最值得展开的是deleted逻辑删除字段。用 MyBatis-Plus 时配置好TableLogic后所有查询会自动追加deleted 0条件删除操作也会变成 Update。对宣传系统来说管理员误删一条景点文章是常见操作有逻辑删除兜底数据恢复成本几乎为零。3.2 栏目内容表如何统一管理不同类型的介绍景点、美食、民俗、旅游路线本质上都是“图文介绍”区别只是栏目类型不同。与其建四张几乎一样的表我更建议用一张栏目内容表加一个category字段区分。CREATE TABLE t_content ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category VARCHAR(20) NOT NULL COMMENT 栏目类型food/culture/route/scenic, title VARCHAR(100) NOT NULL COMMENT 标题, cover VARCHAR(255) DEFAULT COMMENT 封面图, summary VARCHAR(500) DEFAULT COMMENT 摘要, detail TEXT COMMENT 详情内容, sort INT DEFAULT 0, status TINYINT DEFAULT 1, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, KEY idx_category_status_sort (category, status, sort) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT栏目内容表;这种设计最大的好处是后台管理逻辑可以复用。新增“手工艺”栏目时不需要新建表、不需要新写 Controller只要页面下拉框里加一个选项即可。坏处是字段比较宽泛不同栏目可能有一些专属字段比如路线需要“天数”。处理方式是预留一个extraJSON 字段存专属属性MySQL 8 支持 JSON 类型查起来并不麻烦。3.3 留言表与基础字段规范留言表非常典型核心字段是内容、昵称、审核状态。CREATE TABLE t_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(50) NOT NULL COMMENT 游客昵称, content VARCHAR(500) NOT NULL COMMENT 留言内容, target_type VARCHAR(20) DEFAULT general COMMENT 留言对象类型, target_id BIGINT DEFAULT 0 COMMENT 留言对象ID, status TINYINT DEFAULT 0 COMMENT 0待审核 1已通过 2已驳回, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT DEFAULT 0, KEY idx_status_time (status, created_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT留言表;target_type和target_id是通用的评论关联设计既支持“给景点留言”也支持“给某条美食推荐留言”不用为每个业务单独建评论表。前台只查status 1的内容后台默认展示status 0的待审核记录审核动作就是改一个字段。提示所有表都必须有created_time和updated_time。用数据库默认值维护可以省很多事不要靠后端代码手动 set 时间既容易漏又会造成不同服务器时间不一致。4. 核心代码串讲从后端接口到前端渲染的完整链路4.1 后端一条接口的完整闭环很多初学者拿到源码喜欢先看 Controller其实看代码应该沿着“实体类 → Mapper → Service → Controller”的顺序走。以景点列表接口为例实体类Attraction对应t_attraction表Mapper 继承BaseMapperAttraction后单表查询方法全部现成。Service 层的核心逻辑用 MyBatis-Plus 的分页查询加条件构造器实现Override public PageResultAttraction pageList(int page, int size, String city) { PageAttraction p new Page(page, size); LambdaQueryWrapperAttraction wrapper new LambdaQueryWrapper(); wrapper.eq(Attraction::getStatus, 1) .eq(StringUtils.isNotBlank(city), Attraction::getCity, city) .orderByDesc(Attraction::getSort) .orderByDesc(Attraction::getId); attractionMapper.selectPage(p, wrapper); return new PageResult(p.getRecords(), p.getTotal()); }Controller 只做参数接收和结果包装不写业务逻辑RestController RequestMapping(/api/attractions) public class AttractionController { GetMapping(/list) public ResultPageResultAttraction list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size) { return Result.success(attractionService.pageList(page, size)); } }这里要注意LambdaQueryWrapper的.eq()方法可以传布尔条件第一个参数为true时才拼接 SQL。这样“城市筛选”为空时不会生成WHERE city IS NULL这种错误条件代码也更干净。4.2 前端页面如何调用接口前端层面我习惯在src/utils/request.js里封装一次 Axios统一配置基础 URL、超时时间、响应拦截器。之后每个页面只需要关心数据不需要重复处理错误提示。import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(网络异常) return Promise.reject(error) } ) export default request以首页景点列表为例页面组件里只需要调用一次request.get(/attractions/list)拿到的res就是Result.success里的data也就是PageResult对象。这里有个先决条件前后端联调时跨域必须解决否则前端baseURL配置得再规范也访问不到后端这个问题我在第六部分详细说。4.3 图片上传与静态资源映射宣传系统里图片上传是刚需。我的方案是上传接口接收MultipartFile落盘到服务器指定目录然后把文件的访问路径返回给前端保存。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename UUID.randomUUID().toString().replace(-, ) ext; File dir new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return Result.success(/uploads/ filename); }后端还需要配置静态资源映射把/uploads/**映射到实际目录。开发环境下用 Java 配置生产环境下我后面讲 Nginx 时再说明。图片名用 UUID 重命名是为了避免中文文件名、重名文件带来的一系列乱麻问题这个坑相当多前端同学踩过。5. 部署上线从本地联调到 Linux Nginx 的完整流程5.1 本地启动的三步流程拿到一套源码最怕文档写得不清楚第一步就卡在环境上。我写部署文档的时候固定按三步走初始化数据库先执行database.sql创建库表并插入测试数据再用admin.sql写入管理员账号。启动后端修改application.yml里的数据源地址、用户名、密码进入项目根目录执行mvn spring-boot:run。启动前端执行npm install安装依赖再执行npm run dev打开本地开发地址。这三步看起来简单但文档里必须写清楚 JDK 和 Node 的版本要求。我遇到过 JDK 11 环境跑 Spring Boot 3 项目直接报错的也遇到过 Node 版本过旧导致 Vite 起不来的。部署文档里把版本写死能省掉大量“为什么我跑不起来”的咨询。5.2 生产环境部署到 Linux Nginx生产环境建议把前后端分开部署前端构建后的静态文件交给 Nginx后端用java -jar跑在一个独立端口再用 Nginx 反向代理。前端构建命令是npm run build构建产物会在dist目录下把整个目录上传到服务器/usr/share/nginx/html即可。Nginx 核心配置如下server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads/ { alias /var/www/upload/; } }这里有几个非常关键的点。第一try_files $uri $uri/ /index.html是为了解决 Vue Router 的 history 模式刷新页面 404 问题不配这一行用户从“景点详情页”按 F5 就会直接白屏。第二/api必须保留尾部斜杠和路径前缀proxy_pass http://127.0.0.1:8080/api/;会让后端收到的请求路径保持完整。第三/uploads静态目录由 Nginx 直接接管完全不走 Java 应用图片访问性能和资源占用都会好很多。后端打包先用 Maven 跳过测试打包mvn clean package -DskipTests然后上传target/下的 jar 包使用nohup后台运行nohup java -jar home-travel-system.jar --spring.profiles.activeprod app.log 21 生产环境的application-prod.yml里数据库密码不要写明文到配置文件可以用环境变量占位例如${DB_PASSWORD}启动前通过export DB_PASSWORDxxx注入。这套做法对小型项目来说安全性和易用性平衡得最好。5.3 文档应该怎么写才不算“凑字数”源码文档和部署文档是这类项目的硬通货。很多学生的文档是从网上扒的模板大段复制系统功能描述唯独没有“照着做就能跑起来”的信息量。我自己整理这类项目文档的固定结构是项目简介用 3 句话讲清楚这是什么系统解决什么问题。技术栈与环境版本表JDK、Maven、Node、MySQL 的精确版本。数据库初始化步骤建库命令、导入脚本、预期结果。前端启动方式安装依赖、开发调试、构建命令。后端启动方式配置文件修改项、启动命令、常见报错对照。生产部署服务器目录规划、Nginx 配置、jar 包运行、日志查看方式。常见问题端口占用、数据库连接失败、跨域、图片不显示。文档不是论文不需要长篇大论讲“研究意义”。按“别人照着做、十分钟内能跑起来”的标准来写这份文档才是真正有用的。6. 联调与维护期踩过的坑跨域、图片路径、查询性能6.1 前后端联调的跨域问题本地开发时前端跑在5173后端跑在8080端口不同就必然触发跨域。我见过太多项目卡在这一步前端axios请求发出去控制台报CORS error然后开始各种改乱配置。解决办法有两种。第一种是后端写一个全局 CORS 配置类放行开发环境的请求Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }第二种更推荐利用 Vite 的代理功能前端开发环境下请求/api时自动转发到后端这样浏览器感知不到跨域。但很多部署文档都只写了第一种等上线后前后端不再跨域也就没事了。如果两种都配注意allowCredentials(true)时不能配合addAllowedOrigin(*)要用addAllowedOriginPattern(*)这个小细节容易踩。6.2 图片路径返回相对还是绝对图片路径的坑必须在项目一开始就统一约定。我的建议是数据库里永远只存相对路径比如/uploads/xxx.jpg前端渲染时拼上当前域名。原因很简单。开发环境的域名是http://localhost:5173后端上传后返回的相对路径由 Vite 代理转发图片能正常显示。如果数据库存的是http://localhost:8080/uploads/xxx.jpg部署到线上后这条绝对路径就是无效的改域名时还要批量改数据。前端可以用一个全局变量保存baseUrl展示时做拼接export const baseUrl import.meta.env.DEV ? : http://your-domain.com注意生产环境如果走了 Nginx 的/uploads直接映射baseUrl其实只需要一个空串或当前域名前缀不要让图片请求绕过 Nginx 再去打 Java 应用。6.3 N1 查询与列表性能宣传系统数据量不大一般不会有性能瓶颈但代码写法上容易埋雷。最典型的是“前台首页聚合多个栏目数据”时用循环查询数据库。比如页面要展示“热门景点 4 条、推荐美食 4 条、特色民俗 4 条、旅游路线 4 条”新手容易写出四个接口前端依次请求每个接口查一次库这本身没问题。真正的问题是首页轮播图、景点列表、美食列表各自查一遍还要查询关联的留言数如果循环里查留言表就会变成 N1 次查询。我的处理方式是直接写一个聚合查询接口/api/home/index一次查出首页所有板块数据。后端用Map组装返回前端只请求一次。数据量小的时候这种方式简单高效数据量大了再引入缓存也不迟。判断标准很简单宁可一个接口返回多一点也别让前端发 20 个请求把首页拖到两秒才能打开。另外列表接口一定要做分页。即使前台首页只展示前 10 条也建议在后端用LIMIT控制而不是查全量再用前端slice。这类写法一旦演示数据量变大页面会立刻变卡到时候再回头改分页就麻烦了。最后再分享一个我自己反复用到的判断标准这类宣传系统好不好用不在于功能多不多而在于“需求方能不能自己维护内容”。所以设计后台时所有展示字段都能在后台编辑所有图片都能通过后台上传所有菜单顺序都能手动调整。把内容维护的成本降到最低这套系统才算真正交付完成而不是交完源码就结束。