1. 项目是怎么从“想做一个”变成“真的做出来了”的
1.1 为什么选这个题目,以及它的真实价值
先说说我做这个项目的背景。在学校里待过的人应该都有这种体会:大四毕业季,一堆考研资料、专业书、小台灯、收纳架闲置下来,扔了可惜,搬走又太重;大一新生入学的时候,又得花高价买一堆全新的生活用品。这种需求是真实存在的,而且每年都在循环。当时我就想着能不能做一个校内的小平台,让这些东西在同学之间流转起来。这个念头本身不稀奇,但真正让我决定动手的原因是我想要一个能同时练到前端、后端、数据库设计三个方向的完整项目,而不是像平时作业那样只写个页面或者只写个接口。
技术栈我选的是 Vue + SpringBoot + MySQL。这个组合不是拍脑袋定的,三个角色各有各的分工:Vue负责页面渲染和用户交互,SpringBoot负责把业务逻辑封装成接口,MySQL负责把数据落盘。换句话说,前端是脸面,后端是大脑,数据库是记忆。对一个想在校招简历上写“完整全栈项目”的人来说,这三样东西缺一不可。
这个项目的目标用户很明确,就是在校学生。所以整个系统的设计要围绕“校内”“二手”“交易”这三个关键词转。所谓校内,就是用户要能验证学生身份,不然就失去“校内”的意义;所谓二手,是商品本身要有时效性和状态描述,比如几成新、有没有瑕疵;所谓交易,是得有发布、浏览、下单、确认收货这个闭环,不能只是一个商品展示墙。
说到这里,你可以把这个项目拆成两个视角来看:管理员视角和学生视角。学生能注册、登录、发布闲置、浏览商品、收藏、下单、确认收货、管理个人发布;管理员能审核商品、管理用户、处理举报。这个权限划分一开始就要想清楚,不然后面改起来非常痛苦,尤其是数据库字段和接口权限那一层,前期设计偷懒,后期就是给自己挖坑。
1.2 这套技术选型背后,我踩过的选择和权衡
先把话放这儿:Vue + SpringBoot + MySQL 不是唯一解,但是对校园二手交易这个场景来说,性价比极高的一组搭配。
Vue这边我选的是Vue 2 + Vuex + Vue Router + Axios这套经典组合。为什么不用Vue 3?不是Vue 3不好,而是Vue 2的生态资料更全面,遇到问题搜索解决方案的时候命中率更高。对一个第一次完整做全栈项目的人来说,能快速定位问题比用上最新特性更重要。页面交互上我用饿了么的组件库,表格、表单、分页这些现成的组件直接拿来用,能省掉大量样式上的重复劳动,把精力放在业务逻辑上。
后端SpringBoot我用的是2.x版本,配套MyBatis Plus做持久层。MyBatis Plus对新手特别友好,它把单表的增删改查封装好了,写代码的时候不用每个方法都写一遍SQL,尤其是分页查询,一个Page对象传进去就行。对于那些需要多表联查的复杂场景,它也能支持手写SQL,不会封死你的路。很多人一开始会纠结用JPA还是MyBatis,我的观点是,如果你更看重SQL的可控性和团队的熟悉度,选MyBatis Plus基本不会后悔。
MySQL这边主要考虑的是事务和索引。交易系统涉及钱和物,订单状态不能乱,所以订单表的下单操作必须放在事务里,这个在后端代码里用@Transactional注解解决。索引这块,商品表的分类、发布状态、创建时间三个字段后面一定会频繁用于查询,务必建上索引,不然数据量一上来,首页接口就会肉眼可见地变慢。
另一个关键点是文件存储。二手商品一定要有实拍图片,不然买家没法判断成色。图片不能直接塞进MySQL的BLOB字段里,正确做法是把图片存到服务器的某个目录,数据库只存访问路径。这个在小项目里最省事,也不用额外引入对象存储服务,唯一要注意的就是路径别写死成绝对路径,不然后面换个环境部署就全裂了。
到这里,整个项目的骨架已经在我脑子里立起来了。前端单页应用,后端REST接口,数据库六张核心表,图片走本地存储,用户权限用JWT做无状态登录。下面我按实际开发顺序,从数据库设计开始一步一步讲清楚。
2. 数据库设计:六张表怎么撑起整个交易闭环
2.1 用户表、商品表、分类表的设计与字段取舍
数据库是后端开发的地基。前面的接口写得再花哨,表设计不合理,后面联查的时候必然后悔。我的做法是先画一张简单的ER图,把实体和实体之间的关系捋清楚,再动手建表。
第一张是用户表(user)。字段我设计为:id(主键自增)、username(登录账号)、password(加密存储)、nickname(昵称)、avatar(头像路径)、phone(联系电话)、college(学院或校区)、role(角色字段,1表示学生,2表示管理员)、status(账号状态,0正常,1封禁)、create_time(注册时间)。这里有几个细节要说明:密码必须用加密方式存储,我选的是BCrypt,不是明文;role字段虽然当前只有学生和管理员两种,但设计成整数类型而不是布尔值,是为了后续万一要加“超级管理员”或者“商家”角色时不需要改动表结构。
第二张是商品表(goods)。核心字段有:id、user_id(发布者ID)、category_id(分类ID)、title(标题)、description(描述)、price(价格)、original_price(原价)、quality(成色描述)、images(图片路径,多图用逗号分隔)、status(状态字段,0在售,1已售出,2下架,3待审核)、view_count(浏览量)、create_time、update_time。价格字段要注意,不要用float或double,钱这种东西用decimal(10,2)最稳妥,否则会出现0.1加0.2不等于0.3的尴尬情况。status字段是这个表的灵魂,每一次状态变化都要在代码里明确约束,比如只有status为0的在售商品才能被别人下单。
第三张是分类表(category)。这个表字段很简单,id、name、sort_order。内容可以是预设的:书籍教材、数码电器、生活用品、运动器材、美妆服饰、其他。分类做成表而不是直接在前端写死,是为了方便以后扩展。有人觉得多一张表多一份麻烦,但实际经验是,分类数据放在表里,你后面调整分类名字、排序,改数据库就行,不用动前端代码重新发布。
第四张是订单表(orders)。这可能是整个系统里最容易出错的一张表。字段有:id、order_no(订单编号)、goods_id(商品ID)、seller_id(卖家ID)、buyer_id(买家ID)、price(成交价格)、status(状态:0待付款,1待发货,2待收货,3已完成,4已取消)、create_time、pay_time、confirm_time。之所以要把goods_id、seller_id、buyer_id都单独拎出来而不是只存一个goods_id,是因为订单详情页面需要同时展示商品信息、卖家昵称、买家昵称,存冗余字段可以减少联查次数,这也是典型的空间换时间思路。
第五张是收藏表(favorite)。因为是二手交易平台,收藏是个高频操作。字段为:id、user_id、goods_id、create_time。查询时直接根据user_id去查关联的goods_id列表,然后返回到商品详情接口里。这张表比较轻量,但别忘了对user_id和goods_id建联合唯一索引,防止同一用户重复收藏同一商品。
第六张是评论表(comment)。对于二手交易来说,交易评价和留言评论可以合一,不用做得太复杂。字段为:id、goods_id、user_id、content、reply_to(回复某条评论的ID,可空)、create_time。为什么需要一个reply_to字段而不是直接做两级回复表?因为实际场景里“评论+回复”的需求最多到两层,一张表的reply_to就能搞定。你要真的做成无限楼层,那得用邻接表模型甚至闭包表,对一个小项目来说完全没有必要。
2.2 外键到底建不建?我的选择和理由
建设表的时候有一个争议点:要不要在表之间建立外键约束。教科书上通常建议建外键以保证完整性,但实际开发中,尤其是互联网项目里,外键带来的问题比解决的问题多。
我最终的选择是:不建物理外键,只在逻辑层面通过代码控制关联。举个例子,删除一个用户之前,先查询这个用户有没有未完成的订单,有就禁止删除或者改为封禁账号,而不是让数据库去级联删除。
理由有三点。第一,外键约束会让插入和更新操作的性能打折扣,因为每次操作都要检查关联表的状态。第二,级联删除很危险,万一误删一个用户,他的所有发布记录全部被连带删掉,这种事故没法恢复。第三,项目后期如果需要分表或者迁移数据,外键会成为巨大的阻碍。
当然,不建外键的前提是,代码逻辑必须自己保证一致性,比如下单时要校验订单里关联的商品确实存在,并且status是0。通过事务和业务校验来控制,效果并不比外键差。
2.3 事务的必要性:下单流程为什么要放在同一个事务里
二手交易系统的核心动作是下单,它涉及的操作不止一步:先要校验商品状态是在售,然后创建订单记录,再把商品状态改成已售出,最后扣减卖家的库存(虽然这里没有库存概念,但状态变迁是必须的)。这几步操作必须捆绑成一个原子操作,要么全部成功,要么全部失败。
SpringBoot里做这件事非常简单,在Service方法上加一个@Transactional注解就行。但这里我要提醒你一个很隐蔽的坑:@Transactional注解默认只在抛RuntimeException时才回滚,受检异常不会触发回滚。所以如果你的校验逻辑里用了throws Exception这种方式,事务不会生效。正确做法是统一抛自定义的业务异常,或者明确指定rollbackFor = Exception.class。
另一个安全细节:下单时要开启乐观锁或者状态校验,防止两个人同时看到同一件商品然后一起下单。我的做法是在更新商品状态时用一条带条件的UPDATE语句:UPDATE goods SET status = 1 WHERE id = ? AND status = 0。如果这条语句影响的行数为0,说明商品已经被别人买走了,此时直接抛出“商品已售出”异常。这个方式简单可靠,比纯粹在Java代码里加锁要来得实在。
3. 后端核心接口设计与业务实现
3.1 登录鉴权:JWT无状态登录是怎么落地的
校园二手交易系统的用户身份认证,我用的是JWT方案。为什么不用Session?因为前后端分离的项目里,Session天然不占优势。前端跑在一个端口,后端跑在另一个端口,跨域请求带上Cookie本来就麻烦,而且Session是存储在服务器内存里的,服务重启之后全部失效,用户就掉线了。
JWT的思路是把用户ID、角色、过期时间这些信息加密成一串Token,后端签发的Token由前端保存(通常是localStorage),前端每次请求时把它放在请求头Authorization字段里传回来。后端通过拦截器统一解析Token,要么放行,要么返回401。
具体实现上,我引入的是jjwt这个Java库。生成Token的代码大同小异,核心是配置一个签名密钥和过期时间。密钥我建议放在配置文件里,不要写死在代码中。过期时间我设置的是7天,对于校园内部使用来说是够用的。拦截器要拦住除了登录、注册、商品列表、商品详情、图片访问以外的所有接口,因为未登录用户也应该能浏览商品,这一点对二手的“逛”场景很重要。
还需要处理一个细节:Token过期了怎么办?我选择了最简单的方案,前端在收到401响应时,统一跳转到登录页,同时清空本地存储的Token。复杂一点的方案是续期Token,但对这个项目来说属于过度设计。
3.2 商品发布与图片上传:本地存储方案实操
商品发布是个典型的表单提交场景。前端把文字字段和图片文件分开处理:先上传图片,拿到图片的访问路径后,再把携带图片路径的表单数据提交给后端接口。
图片上传接口的设计要注意几个点:文件格式限制(只接收jpg、png、jpeg,防止恶意文件上传)、文件大小限制(单张不超过5M,防止服务器磁盘爆掉)、文件命名(用UUID或时间戳重命名,防止文件名重复覆盖)。我用的是UUID + 原始文件后缀的组合,比如680e8400-xxxx-xxxx-xxxx-xxxxxxxx.jpg。
存储目录我建议用相对路径加配置文件的方式:配置里写file.upload-dir=/data/school-market/images,然后在代码里用配置类读取这个值。这样以后换服务器,改配置文件就行,不用重新编译代码。静态资源映射也要配置好,让/images/**这个URL前缀能直接映射到本地磁盘目录,这样图片路径就可以直接存成/goods/2024/xx.jpg这种格式,前端访问时拼上服务器域名就能展示。
我自己在开发中踩过的一个坑是:本地上传正常,部署到服务器发现图片加载不出来。排查半天发现是Nginx没配置静态资源的location规则。如果你用Nginx做反向代理,别忘了加一条location /images/ { alias /data/school-market/images/; }这样的配置。
3.3 商品列表与搜索:分页、筛选与排序的实现思路
首页就是商品列表页,它要支撑起“浏览”的核心体验。我实现了一个支持分页、分类筛选、按价格和发布时间排序的接口。参数设计为:pageNum、pageSize、categoryId、keyword、sortField、sortOrder。
后端用MyBatis Plus的分页插件,核心代码非常简单:传入一个Page对象,再传入一个LambdaQueryWrapper,插件会自动生成带LIMIT的SQL,并把总记录数一并返回。
keyword搜索这里有个实用技巧:不要直接用LIKE '%keyword%'去搜所有字段,这样效率极低。我是在goods表里增加了title和description的搜索条件,用LIKE匹配这两个字段,并且使用了MySQL索引优化中的左前缀规则。完全匹配的关键字还有一个隐藏场景——很多学生搜索“四级真题”时会输入“四级”,结果里应该能匹配到“大学英语四级真题汇编”,所以LIKE模糊匹配在业务上是必须的,不用过度纠结性能,数据量小时完全够用。
商品状态默认只显示status=0的在售商品,但管理员接口要能看到全部状态,所以查询条件要支持动态传入。这个在MyBatis Plus中就是wrapper.eq(status != null, "status", status)这种写法,条件构造器很灵活。
3.4 订单流程:从下单到完成的状态机设计
订单状态机是整个系统里最需要细心的地方。状态流转必须严格单向控制:
- 待付款(0) → 待发货(1):买家点击支付(虽然校园二手通常线下交易,但线上流程要有)
- 待发货(1) → 待收货(2):卖家点击发货
- 待收货(2) → 已完成(3):买家点击确认收货
- 任意状态 → 已取消(4):买家取消或被管理员关闭
状态变更必须由对应角色触发,比如点击确认收货的必须是买家,点击发货的必须是卖家,后端接口要从JWT里解析出当前用户的ID,和订单里的seller_id或buyer_id比对,不一致直接抛异常。这个校验不能省,前端隐藏按钮只是体验优化,后端的权限校验才是安全防线。
4. 前端页面拆解与交互实现
4.1 页面路由与状态管理:Vue全家桶的安排
前端路由我用Vue Router,页面划分大概是这样:首页(商品列表)、商品详情、发布商品、个人中心、我的订单(买/卖两个Tab)、登录注册页、后台管理(管理员专属)。路由配置里最需要注意的是懒加载,用import函数动态引入组件,而不是把所有组件打包进一个JS文件里。这样首屏加载速度会明显更快,尤其是图片多的二手平台页面。
状态管理用Vuex,主要存储三类数据:用户信息(登录后从后端接口获取并存入)、分类列表(首次加载后缓存,避免每个页面重复请求)、当前搜索条件(列表页和详情页之间跳转时要保留关键词)。这只是为了功能上减少没必要的重复请求,不至于在页面切换时丢失状态。
页面跳转时还有一个常见问题:从详情页返回列表页,往往希望停留在上一次滚动的位置。Vue Router的scrollBehavior可以配置成记录路由栈的滚动位置,这个小细节很多人忽略,但能明显提升使用体验。
4.2 商品发布页面:图片拖拽上传怎么做
发布页的表单字段包括:标题、描述、分类(下拉选择)、价格、原价、成色描述(下拉选择:全新/几乎全新/轻微使用痕迹/明显使用痕迹)、图片上传(多图)。图片上传组件我基于ElementUI的Upload组件封装了一层,核心配置是:action指向后端的上传接口、headers带上Authorization Token、limit限制最多6张、on-success回调里把返回的图片路径push进一个数组里,最后提交表单时把图片数组用逗号拼接成一个字符串存储。
有个体验细节值得分享:上传图片后要立即在页面上预览,而不是等表单提交后才回显。ElementUI的Upload组件自带list-type="picture-card"模式,能直接实现预览和删除,省了不少事。
4.3 商品详情页:浏览、收藏、下单的联动逻辑
商品详情页的数据来源是/goods/{id}这个接口,返回字段包含商品基本信息、卖家信息、当前登录用户是否已收藏该商品。页面布局大致是:左侧图片轮播,右侧标题、价格、成色、卖家信息、收藏按钮、下单按钮。
收藏按钮的交互是重点:如果未登录用户点击收藏,要引导去登录,而不是直接调接口报401。已登录用户点击收藏时,前端根据接口返回的状态判断当前是已收藏还是未收藏,切换按钮样式并调用对应的收藏/取消收藏接口,这里我用了防抖处理,避免用户快速连点时发出重复请求。
下单按钮的判定逻辑也要处理:如果商品是当前登录用户自己发布的,按钮要禁用,显示“不能购买自己发布的商品”。如果商品已经售出,按钮要显示“已售出”并禁用。前端判断只是体验优化,后端同样要在下单接口里做对应校验,双保险。
5. 开发过程中最容易踩的坑,逐个呈堂证供
5.1 前端跨域问题:一次配置终身受益
前后端分离开发,跨域是逃不掉的第一道坎。前端跑在localhost:8080,后端跑在localhost:9090,两者端口不同,浏览器默认会拦截跨域请求。我在后端写了一个CorsConfig配置类,实现WebMvcConfigurer接口,重写addCorsMappings方法,允许所有来源访问并开放所有方法和请求头。
这里有个坑:allowedOrigins不要写成“”并同时允许携带凭证(allowCredentials=true),这样组合会导致某些浏览器直接拒绝请求。如果你需要携带Token,要么使用allowedOriginPatterns,要么明确指定允许的来源列表。开发阶段图省事,我用的allowedOriginPatterns("")配合allowCredentials(true),实测稳定。
5.2 时间格式统一:避免时区和格式错乱
前后端联调时经常发现时间字段显示成一串数字,或者差了8个小时。问题在于Jackson默认序列化LocalDateTime的方式,以及MySQL驱动连接字符串里的时区参数。我的解决办法是三步齐走:
第一,MySQL连接串里加上serverTimezone=Asia/Shanghai,确保数据库连接时的时区一致。第二,在SpringBoot里配置Jackson的全局格式,将LocalDateTime统一序列化为yyyy-MM-dd HH:mm:ss。第三,前端拿到格式化后的字符串后直接用,不要再做new Date()处理,否则又会产生时区偏移。这三步配合做完,时间显示就稳了。
5.3 图片显示不出来的排查套路
图片问题看起来很简单,但真正排查起来链路很长,可能是前端路径拼错、可能是后端映射没生效、可能是Nginx挡住、也有可能是权限拦截器把图片请求拦了。我总结了一套排查顺序:先用浏览器直接访问图片的完整URL,看能不能打开;打不开就去看后端日志有没有报错;没报错就去看访问的路径是否命中了静态资源映射;如果命中了,再看请求是否被拦截器拦截。按这个顺序逐层排除,比抓瞎要高效得多。
6. 部署上线时的几条硬经验
6.1 环境配置与打包命令备忘
后端打包我用的Maven,命令是mvn clean package -DskipTests,打出来是一个可执行的Jar包。如果要在服务器上后台运行,用nohup命令启动,并指定端口和配置文件。前端打包用npm run build,产物是dist目录,需要配置Nginx把静态文件指到dist目录,同时把/api的请求反向代理到后端的Java进程。
这里有一个容易出问题的点:前端请求后端的URL不能写死成localhost。我在前端代码里把接口基础地址提取成环境变量,通过Vue CLI的.env.development和.env.production两个文件分别配置,开发环境指向本地后端,生产环境指向服务器地址,打包时自动替换。
6.2 上线前的安全检查清单
上线前我按这个清单过了一遍,建议你也照着做:
- 密码字段确认加密存储,登录接口返回的用户信息里不包含password
- 商品发布、下单、管理等接口都做了登录校验
- 管理员接口额外校验了角色,普通用户访问直接返回403
- 上传接口限制了文件类型和大小
- 数据库备份策略确认(哪怕只是手动mysqldump,都要有)
- 关键日志有没有打印,尤其是下单失败、支付失败这类异常路径
- 服务器端口和防火墙规则是否放行
清单虽然基础,但每一条背后都有翻车案例支撑。我见过有人因为登录接口返回了密码字段,脱库后明文密码全泄露;也见过有人上线之后发现管理员接口谁都能调。小项目也是项目,该有的安全意识一个都别省。
7. 写在最后:几个让我少走了很多弯路的体会
7.1 先写完核心链路,再考虑锦上添花
我第一版就用了整整两周做发布商品、浏览、下单、确认收货这四个核心功能,评论、收藏、后台管理全部放在后面。好处是我能保证主链路在任何时候都能跑通,不会出现做了很多模块但没一个能完整演示的窘境。对新手来说,先跑通一条主链路带来的正反馈,比在多个模块里反复横跳要强得多。
7.2 日志别偷懒,调试时救命的概率极高
我之前习惯依赖断点调试,觉得打日志麻烦。但一旦部署到服务器上,断点根本没法用,只能靠日志分析问题。后来我学乖了,在关键接口上统一使用注解收集日志,或者至少在每个Service方法的入口和出口打印参数与结果。特别是在订单状态变更的代码里,加上操作者、变更前后状态、操作时间这三个维度的日志,遇到纠纷时能说清楚是谁做了什么。
7.3 自己多体验几遍“买家”和“卖家”的完整流程
测试的时候千万不能只站在管理员或开发者的角度,要模拟真实的用户操作。我试过把发布者、买家、管理员三种角色分别注册登录一遍,完整走完发布→购买→确认收货→评价这条链路。过程中真的发现了几个问题:比如卖家在“我发布的”列表里看不到自己商品的当前状态;比如买家购买后再次进入商品详情页,能看到已售出,但是在订单列表里找不到该笔订单。这些问题如果不亲历用户视角,真的很难靠代码review发现。
整套系统从数据库设计到前后端实现,再到现在稳定跑在服务器上,中间踩过不少坑,但也正是这些坑让我把Vue生命周期、SpringBoot事务、MySQL索引这些原本停留在书本上的概念真正内化成了肌肉记忆。如果你也在做类似的全栈项目,我最想提醒你的一句话是:业务逻辑的边界条件一定要在编码之前想清楚,后面你会感谢自己当初多想的那五分钟。