拿到一份旅游网项目的源码,很多人第一反应是先把它跑起来,跑不通就一头扎进配置文件里东改西改。我之前整理过一个黑马旅游网项目的代码,它属于早期Java Web入门的典型形态:用户登录注册、线路分类展示、线路详情、收藏、订单提交,后端用的是Spring + SpringMVC + MyBatis这套经典组合。这篇内容就是围绕这个项目的完整开发思路来写,包括需求怎么拆、表怎么建、编码时哪些地方容易翻车,最后还要聊一聊源码怎么整理才不糟蹋。如果你正准备做类似的旅游网站、毕业设计,或者手里有一份能跑的代码但不知道怎么归档成能交付的代码,这篇应该能帮你省下不少熬夜改Bug的时间。
1. 为什么旅游网项目容易做到一半就乱:需求边界要先画出来
1.1 旅游网不是把页面堆出来,而是把业务闭环跑通
旅游网这类项目最容易被做成“一个好看的静态站”。很多初学者拿到需求后,先按着参考站点把首页、列表页、详情页抄一遍,然后把Controller到处加,等做到订单和后台的时候,已经没有整理代码的心思了。我后面整理源码时翻出第一版代码,发现最大的问题不是某个方法写得烂,而是压根没有先定义“这个项目到底要做什么”。
旅游网的业务闭环其实不复杂:用户看到线路列表,点进详情,喜欢就收藏,决定了就提交订单,后台能看到用户和订单。这个闭环必须在写表之前先走一遍。以黑马旅游网项目为例,页面的数量不少,但业务实体就那么几个:用户、分类、线路、收藏、订单。实体之间的关系能用一张关系图讲清楚,后面建表、写接口、做页面都会顺很多。反过来,一上来就开建表,经常是表建到一半发现分类和线路对不上,收藏关系没地方放,订单又不知道存谁的信息,最后只能删了重来。
1.2 功能清单先列出来,再按优先级砍
我建议在动手前先把功能拆成两批,别让“感觉都能做”的错觉带偏节奏。
| 优先级 | 功能模块 | 说明 |
|---|---|---|
| P0 | 用户登录注册 | 没有登录态,收藏和订单无从谈起 |
| P0 | 线路分类与线路列表 | 首页和列表页是核心入口 |
| P0 | 线路详情 | 展示图片、价格、介绍、收藏按钮 |
| P0 | 收藏添加/取消/我的收藏 | 体现用户行为的关键功能 |
| P1 | 订单提交 | 填联系人信息、提交订单,支付可以做模拟状态 |
| P1 | 后台管理 | 线路上下架、订单列表,不做统计报表也行 |
| P2 | 搜索、排序、分页优化 | 属于增强项,前期能用简单版替代 |
不要一开始就把支付、短信验证、优惠券、会员等级全部塞进去。一个旅游网项目真正能让你学到东西的范围,就是上面P0加P1这部分。支付真没必要接第三方网关,做个模拟成功状态就够了;短信验证也可以用后端打日志代替。把范围控制住,你才有精力把每个功能做扎实,而不是每个功能都是半吊子。
2. 技术栈不是越新越好:SSM组合为什么适合复制
2.1 经典框架组合反而更容易让你理解请求的走向
旅游网项目现在网上能搜到的版本,大部分是Spring + SpringMVC + MyBatis(SSM),也有少部分改造成了Spring Boot。我其实挺推荐先做SSM版本,原因不是它比Spring Boot先进,而是它的请求链路足够直白:浏览器发请求,DispatcherServlet拦截,Controller接参,Service写业务,Mapper写SQL。每一层你都能肉眼看到数据在哪里转了一圈。Spring Boot把很多东西自动配置了,新手照着写很容易跑起来,但真要问“为什么这个配置生效”,反而要多翻好几层封装。
这个项目里,Spring主要负责管理Service和Dao的对象依赖,SpringMVC负责路由和参数绑定,MyBatis负责把SQL和Java方法对应起来。三层结构看起来麻烦,但好处是边界清楚。我在整理源码时发现,很多新手喜欢把业务逻辑直接写在Controller里,导致后面想复用某段功能时只能复制粘贴。技术本身不背锅,分层习惯才是关键。
2.2 缓存、支付、前后端分离:这些增强项该不该上
很多人在技术选型时喜欢问“要不要上Redis”“要不要做前后端分离”。我的看法是:如果目标是掌握Java Web的核心链路,先用Session + MySQL把功能跑通;如果是为了面试展示,可以加Redis做缓存,但别在还没跑通主流程时就去琢磨分布式Session。
以旅游网项目为例,收藏数排行是一个很适合用缓存的场景:热度高的线路,收藏数字段频繁被读取加1。真上Redis的话,可以用一个String记录收藏数,用List或Set记录收藏用户ID,但前提是你的基础版本已经稳定。支付模块同理,模拟一下状态流转,订单表里加一个状态字段就够了,真接支付网关需要商户号、回调验签、对账,那是一个独立的大工程,放在这个项目里很容易劝退自己。前后端分离可以做,但要想清楚:如果用JSP,Session天然好用;如果页面用Vue独立部署,就要处理跨域、Token传递、接口权限,复杂度会翻一倍。
版本选择上,建议固定在一个“保守但不老掉牙”的组合:JDK 8、Maven 3.6、Tomcat 8.5、MySQL 5.7或8.0。这套组合的教学资料最多,遇到问题搜一下全是答案,比追求JDK 17或Tomcat 10省心得多。
3. 表结构设计:等建完表再改关系,代价是删库重来
3.1 核心表关系先描述清楚,再写建表语句
表结构是旅游网项目里最值得花时间的部分。我先用文字把关系说清楚:用户和线路之间是收藏关系,所以需要一张中间表;分类和线路是一对多,线路表里存分类ID;用户和订单是一对多,订单表里存用户ID。如果要做订单项,订单和线路之间也会是多对多,这时候需要订单项表来记录每一条线路的购买信息。
一个可以照着改的建表脚本,核心部分是这样:
CREATE TABLE `tab_user` ( `uid` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(32) DEFAULT NULL, `password` varchar(32) DEFAULT NULL, `name` varchar(32) DEFAULT NULL, PRIMARY KEY (`uid`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `tab_category` ( `cid` int(11) NOT NULL AUTO_INCREMENT, `cname` varchar(32) DEFAULT NULL, PRIMARY KEY (`cid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `tab_route` ( `rid` int(11) NOT NULL AUTO_INCREMENT, `rname` varchar(64) DEFAULT NULL, `price` decimal(10,2) DEFAULT NULL, `route_introduce` varchar(255) DEFAULT NULL, `cid` int(11) DEFAULT NULL, `rflag` char(1) DEFAULT '0', PRIMARY KEY (`rid`), KEY `idx_cid` (`cid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;价格字段建议用decimal(10,2)而不是double,金额计算用浮点数到后面容易出精度问题。rflag表示是否上架,后台管理上下架时可以不用删除数据,直接改这个标记,这个设计非常实用。
3.2 收藏表最容易被忽视的两个约束
收藏表是用户和线路的中间表,很多人建表时随便写了两个ID字段就完事。等并发测试或者用户手滑点两下收藏按钮时,就会发现数据重复了。正确的做法是加唯一索引:
CREATE TABLE `tab_favorite` ( `id` int(11) NOT NULL AUTO_INCREMENT, `uid` int(11) NOT NULL, `rid` int(11) NOT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_uid_rid` (`uid`, `rid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个唯一索引有两个作用:第一,防止同一用户对同一线路重复收藏;第二,配合INSERT IGNORE可以很优雅地处理并发插入。另外,查“当前用户是否已收藏某条线路”的接口,不要靠把收藏列表全部查出来再contains判断,数据量一多性能就崩,直接按uid和rid查一条即可。
3.3 订单表要存“快照”而不是“引用”
订单表是另一处容易设计错的地方。很多人会把订单和用户地址、联系人做成关联表,觉得这样很规范。但实际业务里,用户提交订单后,收货人信息是不应该跟着用户资料变化的。你买了东西,后来改了手机号,历史订单里该显示下单时的旧号码才对。所以订单表里直接把联系人姓名、电话、地址作为冗余字段存进去,不要外键关联。
CREATE TABLE `tab_order` ( `oid` varchar(32) NOT NULL, `uid` int(11) NOT NULL, `contact_name` varchar(32) DEFAULT NULL, `contact_phone` varchar(20) DEFAULT NULL, `order_time` datetime DEFAULT NULL, `status` int(1) DEFAULT 0, PRIMARY KEY (`oid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;主键用varchar,因为订单号通常需要业务可读性,比如时间戳加随机串,自增整数主键在分布式场景下反而难处理。
4. 编码阶段最难的三块:登录态、分页查询、收藏幂等
4.1 登录态用Session还是Token:入门项目先把Cookie+Session玩明白
旅游网项目最简单的登录态方案就是Session。用户登录成功后,把User对象放进Session,后面的请求从Session里取。这个方案够用,但有两个问题必须处理:Session默认失效时间是30分钟,用户操作到一半突然过期,体验很差;浏览器关闭后Session不一定马上失效,用户回来发现还在登录态,容易困惑。
我建议在这个项目里实现一个简单版本的“记住我”:登录成功后生成一个随机Token字符串,服务端用Map或Redis保存Token到用户ID的映射,同时写一个Cookie,Cookie的Value就是Token,设置MaxAge为7天。下次请求时,拦截器读取Cookie里的Token,去Map/Redis里找到对应的用户,把用户重新放入当前请求上下文。这样做的好处是,你用的还是Session那一套思维,但已经具备Token鉴权的雏形,以后切换到真正的JWT或者Redis分布式Session时思路是连贯的。
需要注意,Cookie里绝对不能存用户ID和密码的明文组合,也不能只存用户ID,否则别人随便伪造一个Cookie就能冒充用户。一定要用不可预测的随机字符串,并且服务端校验它的有效期。
4.2 分页和条件搜索组合查询:动态SQL一定要配合PageHelper的坑
线路列表页几乎必然需要分页。最基础的做法是手动算offset:
int pageNum = 1; // 当前页码 int pageSize = 8; // 每页条数 int offset = (pageNum - 1) * pageSize;然后Mapper里写LIMIT #{offset}, #{pageSize}。这个思路简单,但一旦和搜索条件混合,就很容易踩坑。比如按名称模糊搜索、按分类筛选,如果只用写死的SQL,会发现每加一个条件就要copy一份SQL。这时应该在Mapper里用动态SQL:
<select id="findByCondition" resultType="com.demo.travel.domain.Route"> SELECT * FROM tab_route <where> <if test="rname != null and rname != ''"> AND rname LIKE CONCAT('%', #{rname}, '%') </if> <if test="cid != null and cid != ''"> AND cid = #{cid} </if> </where> ORDER BY rid DESC LIMIT #{offset}, #{pageSize} </select><where>标签会智能处理第一个条件前面的AND,这是MyBatis最实用的功能之一。如果你想用PageHelper分页插件,也不是不行,但要注意一个常见问题:PageHelper在统计总数时,如果SQL里有DISTINCT或GROUP BY,自动生成的COUNT语句可能不正确,导致总页数算错。用的时候最好手动执行一次SELECT COUNT(*)验证结果。
还有一个小细节:前端传来的页码参数必须先转成整数并做边界校验,不能用户传个pageNum=99999就让他执行深分页,LIMIT的offset太大会导致数据库扫描大量数据。简单项目里至少要做“小于1按1处理”的判断。
4.3 收藏功能要处理并发和幂等,别用“先查再插”
收藏按钮的逻辑看起来很简单:用户点击收藏,如果没收藏过就插入一条,如果已收藏就取消。很多人的第一反应是“先查一下,再决定插入还是删除”。这个写法在单用户测试时完全没问题,但只要两个请求同时到达,就可能出现重复数据或者状态不一致。
正确做法是利用收藏表的唯一索引配合SQL的原子性:
@Transactional public boolean saveFavorite(Integer uid, Integer rid) { try { favoriteMapper.insertIgnore(uid, rid); // 影响行数为1表示插入成功,为0表示已存在 return favoriteMapper.countByUidAndRid(uid, rid) > 0; } catch (DuplicateKeyException e) { // 并发插入冲突时直接认为已收藏 return true; } }对应的Mapper使用INSERT IGNORE INTO tab_favorite(uid, rid, create_time) VALUES(#{uid}, #{rid}, NOW())。如果已经收藏过,INSERT IGNORE不会报错,影响行数为0。取消收藏直接DELETE WHERE uid = #{uid} AND rid = #{rid},不用管之前是否存在,影响行数是0说明本来就没收藏过。
至于收藏数量的展示,可以在线路表里加一个favorite_count字段,每次收藏成功UPDATE tab_route SET favorite_count = favorite_count + 1,取消防UPDATE ... SET favorite_count = favorite_count - 1。注意减的时候要加WHERE favorite_count > 0,防止出现负数。这个字段的更新放在收藏服务里,用事务把收藏表和线路表的更新包在一起,避免数据不一致。
5. 联调部署期容易翻车的细节:路径、时区、编码
5.1 图片上传后访问不到:绝对路径是项目部署的第一杀手
旅游网项目里一定会有线路图片,所以图片上传功能基本躲不掉。新手最常见的写法是在代码里硬编码一个绝对路径,比如D:/upload/或者/Users/xxx/upload/,本地跑得很开心,一换服务器就全部404。
我的建议是:图片上传路径通过配置文件定义,然后在SpringMVC或Tomcat里添加一个虚拟路径映射,把URL里的/upload/**指向磁盘里的真实目录。实际存储时,数据库里只保存相对路径/upload/xxx.jpg,页面组装时再拼接域名和上下文路径。这样项目迁移到哪台机器,只要改配置文件里的上传根目录即可。
还有一个细节:上传文件的名字一定要重命名。用户上传的原始文件名可能是中文,或者包含空格、特殊字符,直接存磁盘容易出乱码问题,也容易撞名。用UUID加原始后缀生成新文件名,最省心。保存时还需要判断扩展名白名单,防止有人上传可执行文件,这是安全意识,不是小题大做。
5.2 MySQL8的JDBC时区问题:启动直接报错一小时
如果用的是MySQL 8版本,JDBC连接串里忘记加serverTimezone,Tomcat启动时大概率会看到The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized之类的乱码报错。解决方式很简单:
jdbc.url=jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/ShanghaiserverTimezone=Asia/Shanghai表示使用中国时区,如果你不想写时区,也可以写serverTimezone=UTC,但要注意数据库里存的时间会和本地时间差8小时。项目里所有时间字段,建议统一在Java侧使用java.util.Date或LocalDateTime,不要在实体类里混用String存时间,否则排序和比较会很痛苦。
另外建库时尽量显式指定utf8mb4,不要只写utf8。utf8mb4可以支持表情符号和一些生僻字,现在已经是默认标准了。MySQL 5.7和8.0之间还有一个差异:8.0默认字符集就是utf8mb4,但5.7可能需要手动设置,建议在建库脚本里写清楚。
5.3 前后端参数命名不统一:小问题引发大返工
联调阶段最常见的返工原因不是逻辑错误,而是字段名对不上。前端传的是routeId,后端接参写rid,查出来的结果里又是routeId,三个名字来回变。这不是谁对谁错的问题,是项目里缺少“字段契约”。
我的建议是:所有和前端交互的字段,要么统一用数据库列名,要么定义一层DTO。简单项目里可以直接统一用数据库列名,比如前端传rid、rname,后端就不要再改成routeId。所有接口的返回值也建议统一包一层Result对象:
public class Result { private boolean flag; // 成功/失败 private String message; // 提示信息 private Object data; // 业务数据 }登录超时、参数校验失败、业务异常,都可以通过这个Result统一表达。前端只需要判断flag这一个字段,不用为每个接口单独写异常分支。这个习惯养成了,后面做任何项目都受益。
6. 源码整理:代码跑通之后的最后一公里
6.1 README写清楚“怎么跑起来”,比写“怎么设计”更重要
代码能跑通,只是项目完成了一半。很多项目源码其实没有问题,就是缺少一份能让人在十分钟内启动起来的说明。我整理这份旅游网源码时,第一件事不是重构代码,而是把README补齐。
一份合格的README至少要有这些内容:项目技术栈和版本;数据库初始化步骤,包括导入脚本的顺序、数据库名;配置文件修改项,比如JDBC连接、上传路径;启动步骤,是先启动MySQL还是先部署Tomcat;测试账号;常见问题和解决方案,比如时区报错、端口占用、图片404。
我还习惯在README里画一个最简单的目录树,告诉后来的人“Controller在哪、Mapper XML在哪、SQL脚本在哪”。这看起来是小事,但等你三个月之后回头翻自己的项目,会发现这份README能帮你省下大量回忆时间。
6.2 包结构规范和注释:把代码当成交付物来整理
源码整理的第二步是包结构。不要所有类都堆在一个包下面,至少按controller、service、dao、domain、utils、interceptor分一下。拦截器单独放一个包,配置类单独放,常量抽到common包里。这样别人看到包名就知道去哪找代码,比读注释快得多。
注释我建议只写“为什么这么写”,不要写“这段代码做了什么”。比如:
// 收藏状态用Redis缓存,缓存击穿时回源数据库并加锁而像// 查询用户列表这种注释,下一步就是和代码实现脱节,越写越没人信。对于特别复杂的业务方法,写三四行的注释说明前置条件和后续影响,比写一整段流水账更有价值。
无用的代码直接删掉。我在整理时发现很多文件里有大段被注释掉的旧代码,留着只会干扰阅读。如果真的想保留历史版本,请使用版本控制工具,而不是靠注释。
6.3 从旅游网项目往后走向哪里
旅游网项目做完之后,往三个方向扩展最自然。第一个方向是引入Redis:把热门线路的收藏数、用户Session、验证码放到Redis里,然后观察接口响应时间的变化。第二个方向是改造为Spring Boot版本,把SSM的XML配置一点点替换成注解和自动配置,这个过程能加深你对Spring Boot“约定优于配置”的理解。第三个方向是往前端分离走,后端只提供JSON接口,用Vue或微信小程序做前端,这会逼着你重新思考登录态和跨域问题。
整理这份源码给我的整体感受是,旅游网项目能成为经典教学项目,不是因为页面有多炫,而是它把Java Web的核心链路完整地串了一遍。第一次做的时候把跑通当目标,第二遍整理时把结构当目标,第三遍如果还能改成Spring Boot或者加Redis缓存,那这份代码才算真正属于你自己。我每次整理源码都会保留两个版本,一个是能跑通的原始版,一个是自己重构版,改坏了大不了回滚,这个习惯救过我很多次,建议你也试试。