做校园周边美食探索与分享平台这个项目,前后差不多折腾了一个半月。技术栈就是标题里那套固定的组合:后端 Java SpringBoot,前端 Vue3,持久层 MyBatis,数据库 MySQL,整体走前后端分离。说实话,一开始觉得这类项目市面上一抓一大把,真动手才发现,把“探索、发现、决策、分享”这个业务闭环串起来,比单纯写几个 CRUD 接口复杂得多。这篇就把我拆解需求、设计库表、撸接口、搭前端、到最终部署联调的完整思路和踩坑记录写出来,给准备做类似全栈项目,或者正在为毕设、个人作品集发愁的同学做个参考。
1. 项目整体定位与技术选型,为什么非这套组合不可
1.1 校园周边美食场景,要解决的三个核心问题
先聊聊这个平台到底在解决什么问题。校园周边的餐饮生态其实很特殊:商家密集、学生群体消费决策快、对新店和优惠信息极度敏感,但信息高度分散。学生想知道“附近有什么好吃的”“这家店哪些菜值得点”“最近有没有新店开业”,往往只能靠宿舍群口口相传或者翻各种点评软件。但这个平台的定位不是要做一个大而全的美食点评网,而是聚焦“校园周边”这个地理和人群都高度集中的场景,做一个小而精的探索与分享工具。
围绕这个定位,整个项目必须回答三件事:用户怎么发现美食、怎么判断值不值得吃、吃完怎么把体验分享出去。发现对应的是地图探索和分类筛选;判断对应的是商家详情、菜品推荐和真实评价;分享对应的是评论、点赞、收藏和动态化的推荐分发。这三件事环环相扣,缺一个环节,平台的价值就立不住。想清楚这个业务闭环之后,所有功能设计和表结构设计都有了立足点,不至于上来就堆功能。
1.2 技术栈选型的真实权衡过程
后端选 SpringBoot 基本没什么悬念。它的自动配置和内嵌容器特性,让我能在一周内把基础脚手架搭起来,不用像传统 SSM 那样繁琐地配置 XML。Maven 管理依赖也很省心,Spring Data 生态、参数校验、AOP、事务这些能力都是开箱即用,对于单人开发或者小组协作的校园项目来说,能把大部分精力放在写业务而不是搞基建上。
前端选 Vue3 而不是 Vue2,核心原因是组合式 API 带来的代码组织方式。系统里有地图探索页、商家详情页、个人中心这些交互复杂度不低的页面,如果用 Options API,一个组件里数据、方法、计算属性混在一起,页面逻辑一旦长了,维护就是灾难。用了 setup 语法和业务 hooks 之后,每个页面的逻辑可以被拆成独立模块,比如把“地图定位 + 附近商家拉取”抽成一个 useNearbyMerchants 组合式函数,可读性和复用性都高了一个量级。
持久层选 MyBatis 而不是 JPA/Hibernate,这点可能有人会有疑问。我的理由是:这个项目里有大量复杂的查询,比如按经纬度计算距离排序、多条件动态筛选(价格区间、评分、分类)、统计商家评分均值、连表查评论和用户信息。JPA 虽然也能做,但要么写 JPQL,要么搞 Specification,调优起来远没有原生 SQL 直观。MyBatis 的动态 SQL 让我能在一个 XML 里把所有筛选逻辑写得明明白白,尤其是<where>标签自动拼条件,比 Java 代码里拼字符串优雅太多了。而且很多高校课程和企业项目都在用 MyBatis,这套经验迁移性很强。
MySQL 就不用多说了,关系型数据非常契合这个项目的模型:用户、商家、菜品、评论之间天然存在外键关系,事务支持也能保证数据一致性。同时对学生开发者来说,MySQL 安装部署、可视化工具、查找问题方案的资料最多,是最稳妥的选择。
1.3 前后端分离架构,开发效率与后期维护的双赢
前后端分离在这个项目里不是追赶时髦,而是确实能解决实际问题。后端只暴露 RESTful API,不关心页面长什么样;前端通过接口拿 JSON 数据渲染页面,不关心数据从哪张表来。这样做的直接好处是分工清晰:我可以先把后端的接口文档定义好,然后再动手写前端,也可以让前端用 mock 数据先推进页面。后期如果想把 Web 端再套一个移动端壳,接口完全复用,不需要重写逻辑。
架构上大致分成三层。前端 Vue3 应用负责交互和展示,通过 axios 调用后端接口;后端 SpringBoot 项目按 controller-service-mapper 分层,接收请求、处理业务、操作数据库;MySQL 在最底层负责数据持久化。部署时可以前端构建成静态文件丢 Nginx,后端打包成 jar 包独立运行,前端调用接口走反向代理,既解决了跨域,也方便将来扩展服务实例。
2. 业务功能模块拆解,从用户的视角反推系统功能
2.1 用户端:探索、决策、分享一条龙
用户端是这个平台的核心,设计的时候我是按用户的使用动线来排功能顺序的。用户第一次打开页面,看到的是地图模式和列表模式双入口,地图模式直接调起定位,标记出周边 3 公里内的商家;列表模式按默认评分、距离或者销量排序,支持关键词搜索和分类筛选。这就是“探索”阶段,讲究的是快速和直观。
点进任意一家商家,进入详情页,能看到店铺的基础信息、招牌菜品展示、均价、评分、位置导航,以及历史用户的评价列表。菜品菜品列表是第一决策要素,我把每个菜品都做成卡片形态,展示图片、价格、点赞数,用户可以对菜品单独点赞和收藏。评价区支持打分和文字描述,这构成了整个平台最核心的内容资产。分享则体现在用户可以对商家和菜品点赞、收藏,收藏列表在个人中心里可以随时翻看,形成自己的美食清单。
2.2 商家端与管理端,控制内容质量的两道闸门
虽然是校园平台,但不能让任何人随意发布商家信息,不然很快会被垃圾内容淹没。所以我设了商家端和管理端两个后台。商家端支持商家入驻申请,提交店铺名、地址、营业时间、营业执照编号(这个字段做成了选填,避免过度敏感)和联系方式。管理员审核通过后,商家账号才能登录后台,维护自己的店铺简介、上传招牌菜图片、上下架菜品、回复用户评价。
管理端承担的是平台治理职责。管理员能查看用户列表、待审核商家、举报信息、评价内容,并执行禁用、下架、删除等操作。另外我还加了一个简单的数据看板,统计商家总数、用户总数、今日新增评价、热门商家 Top10,方便运营同学(其实就是我自己)看平台整体活跃情况。这两个角色的存在,让系统从单纯的工具变成了有内容生命力的社区。
2.3 功能优先级排序,MVP 阶段哪些必须先做
做这种全栈项目最难的就是控制范围。我的经验是先列出所有能想到的功能,然后按“能不能支撑核心闭环”来排优先级。第一优先级是用户注册登录、商家与菜品浏览、地图附近商家、评价与评分,这四块是闭环的骨架。第二优先级是收藏、点赞、关键词搜索、分类筛选,这是增强体验的肌肉。第三优先级才是商家入驻审核、管理后台、数据看板这类管理性质的功能。
这样排序的原因很简单:核心闭环跑不通,平台就没有任何使用价值。实际开发中我也是按这个顺序推进的,先让用户能注册、能看商家、能写评价,整个流程走通之后,再慢慢把管理端和运营功能补上。如果一上来就想着把所有功能都做到位,很容易陷入细节出不来的状态,我曾经就是因为纠结一个评分组件的样式改了三天,完全偏离了主线。
3. 数据库设计,把业务关系翻译成表结构
3.1 核心表设计与字段说明
数据库是这个系统的地基,表设计直接影响后面所有接口的实现难度。我最终设计了 6 张核心业务表:用户表、商家表、菜品表、评价表、收藏表、点赞表,另外还有商家入驻审核表、系统用户表这类辅助表。每张表都遵循了几个通用约定:主键统一用 bigint 自增 id;创建时间 create_time 和更新时间 update_time 用 datetime 类型;逻辑删除用 deleted 字段做标记,防止误删重要数据;业务状态用 status 字段,比如商家状态 0-待审核、1-已上架、2-已下架。
商家表是整个业务的中枢,字段比较多,包括商家名称、分类标签、联系电话、营业时间、人均价格、综合评分、经度、纬度、详细地址、封面图 URL 等。综合评分这个字段非常关键,它存储的是商家的平均分快照,每次有新的评价产生时,在事务里重新计算并更新。这张表设计好之后,用户端列表页的排序和筛选基本都围绕它展开。
菜品表属于商家表的子表,通过 merchant_id 关联。字段包括菜品名称、图片 URL、价格、描述、月销量、点赞数、上下架状态。评价表是内容核心,包含 user_id、merchant_id、order_id(这里我简化掉了订单表,所以直接关联商家)、评分、评价内容、图片 URL 列表。收藏表是一个典型的多对多中间表,记录 user_id 和 target_type(1-商家、2-菜品)以及 target_id,这样一套表结构同时支持了商家收藏和菜品收藏。
3.2 地理位置存储方案,附近商家怎么查最快
做校园周边美食,地理位置的存储和查询是逃不开的。我的方案是直接在商家表里存经度 longitude 和纬度 latitude 两个 decimal(10,6) 字段,再配合一个虚拟列不存储,直接通过 SQL 计算距离。查询附近商家时用 Haversine 公式,核心 SQL 长这样:
SELECT *, ROUND(6371 * 2 * ASIN(SQRT(POWER(SIN((#{lat} - latitude) * PI() / 180 / 2), 2) + COS(#{lat} * PI() / 180) * COS(latitude * PI() / 180) * POWER(SIN((#{lng} - longitude) * PI() / 180 / 2), 2))), 2) AS distance FROM merchant WHERE status = 1 ORDER BY distance ASC LIMIT #{offset}, #{limit}这个方案够用吗?在数据量几万条的情况下完全够用。因为查询会强制做全表扫描,所以我在实际项目里加了两个优化:一是给经度纬度加一个联合索引,二是用预筛条件缩小范围。比如查询 3 公里内商家时,先根据当前经纬度计算出经纬度的上下界,把结果集先缩小到矩形范围内,再用距离公式精排,响应时间从几百毫秒降到了几十毫秒。对于校园场景,这个性能已经绰绰有余了。如果哪天数据量真的大了,再考虑引入空间索引或者换成专用的地理数据库,但那是后话。
3.3 索引与并发控制经验,避免一张表查到底
表结构定了,索引设计就决定了查询的天花板。我的经验是优先覆盖高频查询路径。比如商家列表页经常用 status、category、avg_score 做筛选排序,就给商家表建了 (status, category, avg_score) 的联合索引;评价表经常按 merchant_id 查评价列表,就给 (merchant_id, create_time) 建了联合索引;收藏表则是 (user_id, target_type, target_id) 唯一索引,顺便保证同一个用户不会对同一个目标重复收藏。这些都是用 EXPLAIN 确认过走索引的,而不是拍脑袋建的。
并发控制主要体现在几个关键点:商家评分更新不能并发覆盖,我用事务加行锁解决;点赞数要防重复点赞,除了唯一索引之外,接口层还要先查后插,并且整个操作放在事务里;商品上下架状态变更,用乐观锁版本号防止误操作。这些细节单独看都很基础,但组合在一起,系统在低并发测试下表现非常平稳,也让我对“数据库不只是存数据的地方,还承担了业务约束”这句话有了更深的体会。
4. 后端核心实现与难点攻克
4.1 包结构分层,从 Controller 到 Mapper 的职责边界
SpringBoot 后端我采用了标准的五层结构:controller 层只做参数接收、数据校验和返回结果封装;service 层负责业务逻辑编排;mapper 层只负责数据库读写;entity 实体类与表结构一一对应;dto/vo 对象专门用于接口入参和出参,绝不在层与层之间直接传递实体类。这套职责划分虽然多点几个文件,但可维护性极好,尤其后期加功能的时候,能很清楚地判断新代码应该写在哪一层。
有一个细节很多人会忽略:Controller 中不要直接用 HttpServletRequest 这类原生对象,参数都通过 DTO 接收并用 JSR 303 注解校验,比如@NotNull、@Size、@Pattern。这样前端传参错误能第一时间被拦下来,而不是等到 Service 层才发现 NPE。统一返回体我设计成标准的Result<T>,包含 code、message、data 三个字段,前端 axios 拦截器根据 code 统一处理成功和失败分支,整条链路非常清爽。
4.2 基于 JWT 的登录认证与权限控制
用户、商家、管理员是三类不同角色,权限必须严格区分。我采用的是 Spring Security + JWT 方案。用户登录成功后,后端签发一个有效期两小时的 JWT Token,包含 userId、username、role 等信息;前端把 Token 存在本地存储,每次请求通过 Authorization 请求头带回。后端自定义一个 JwtAuthenticationFilter,在 Spring Security 过滤器链中解析 Token、校验签名和有效期、把用户信息放入 SecurityContext。
权限控制上我用了两个纬度:接口层面,通过注解@PreAuthorize("hasRole('ADMIN')")限制管理员接口、hasRole('MERCHANT')限制商家接口;数据层面,商家只能操作自己的店铺和菜品,通过 Service 里比对当前登录用户的 id 和资源的 merchantId 来保证。这里有一个很关键的坑:Spring Security 的默认过滤器链和 SpringMVC 的路径匹配规则不一致,导致一些公开接口被拦截。解决办法是明确配置permitAll的路径列表,比如登录、注册、商家列表、商家详情这些只读接口放行,其余全部走认证。
4.3 MyBatis 动态 SQL 与事务设计
MyBatis 是这个项目里提升效率最明显的点。商家列表的多条件筛选是最典型的场景:用户可能按关键字搜、按分类筛、按价格区间过滤、按评分排序,每个条件都可选。如果为每种组合写一个 SQL 方法,组合数是爆炸的。我用一个动态 SQL 解决:
<select id="selectMerchantList" resultType="com.yourproject.vo.MerchantVO"> SELECT m.*, ROUND(..., 2) AS distance FROM merchant m <where> <if test="keyword != null and keyword != ''"> AND (m.name LIKE CONCAT('%', #{keyword}, '%') OR m.category LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="category != null and category != ''"> AND m.category = #{category} </if> <if test="minPrice != null"> AND m.avg_price >= #{minPrice} </if> <if test="maxPrice != null"> AND m.avg_price <= #{maxPrice} </if> AND m.status = 1 </where> ORDER BY ${sortField} ${sortOrder} LIMIT #{offset}, #{limit} </select>这里有一个必须注意的问题:排序字段和排序方向如果直接拼接${},存在 SQL 注入风险。实际项目里我是做了白名单校验的,只允许传固定的几个字段名和 asc/desc,其余值一律返回默认排序。动态 SQL 还有一个隐藏的好处:XML 里的 SQL 可以用 IDE 插件直接连数据库跑,排查慢查询和字段错位非常直观。
事务设计上,评价模块是最需要用事务的。用户提交一条评价,系统要做三件事:插入评价记录、更新商家的综合评分和评价数量、更新用户的美食探索记录。这三步任何一步失败,数据都会不一致。我直接在 Service 方法上加了@Transactional(rollbackFor = Exception.class),并且注意了事务失效的几个情况:类内部方法调用不走代理、异常被 try-catch 吞掉不会回滚、非 public 方法不生效。这些都是老生常谈,但每一条都是我自己踩过坑的。
4.4 图片上传与静态资源映射
商家需要上传菜品图片,用户也可能在评价里带图。我用的方案是:前端把文件通过 multipart/form-data 方式上传到后端的/api/upload接口,后端用 UUID 重新生成文件名,按日期分目录存储到本地磁盘,然后把访问 URL 存到数据库。关键是要在 SpringBoot 里配置虚拟路径映射:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MB web: resources: static-locations: classpath:/static/, file:${upload.path}/这个配置的意思是让 URL 中以/upload/开头的路径映射到磁盘上的物理目录。如果不配置,即使文件已经存到服务器,前端也访问不到图片,这个坑一度让我以为是 Nginx 的问题,排查了半天才发现是资源映射漏了。文件上传的安全限制也很重要:限制文件大小,只允许 jpg、png、webp 等白名单后缀,防止恶意文件直接传到服务器上变成攻击入口。
5. 前端核心实现与体验优化
5.1 Vue3 项目结构与状态管理
前端用的是 Vite + Vue3 + Pinia + Vue Router + Element Plus 这套组合。项目结构按功能划分:api目录统一存放后端接口请求方法,views放页面级组件,components放可复用组件,stores放 Pinia 状态,router配置路由和守卫,utils放 axios 封装和公共函数。这套结构和后端分层是镜像对应的,找代码非常快。
Pinia 主要管理三类全局状态:用户登录信息(Token、昵称、角色)、当前定位的经纬度、全局的兴趣标签筛选条件。用户登录信息是几乎所有页面的前提,路由守卫在每次跳转前检查 store 里的 Token,没有 Token 的敏感页面直接重定向到登录页。定位信息做成全局状态很有必要,因为地图探索页和列表页都要用,切换页面不能丢掉。整个状态管理逻辑简单直接,几乎没有学习成本。
5.2 axios 封装与接口联调关键细节
axios 封装是前后端联调体验的分水岭。我做了一个统一实例,请求拦截器里注入 Token,响应拦截器里统一处理返回体逻辑:code 为 200 时正常返回 data,code 为 401 时清除本地登录状态并跳转登录页,code 为其他值时用 Element Plus 的 Message 组件弹出错误提示。这样业务代码里只需要关注数据本身,不用每个接口都写一遍错误处理样板代码。
开发环境的跨域问题通过 Vite 的 proxy 配置解决,把/api开头的请求代理到http://localhost:8080,浏览器看到的请求是同源的,不需要后端额外配置跨域。但有两点要注意:一是生产环境如果前后端分开部署,Nginx 也要配置同样的反向代理逻辑;二是后端在开发阶段可以开启 CORS,但生产环境务必关闭,否则会暴露不必要的跨域风险。联调阶段还有一个非常实用的技巧:Mock 数据不要删,把它保留在 vite.config.js 的一行注释里,后端如果临时出问题,前端随时能切回 mock 模式继续开发,不阻塞进度。
5.3 地图探索页实现思路
地图探索页是前端最核心的页面。我用的是常见的地图 SDK,先在onMounted里初始化地图实例,获取当前定位的经纬度,然后调用后端接口拉取附近商家列表。拿到数据后用AMap.Marker在地图上打点,点击标记点会弹出信息窗体展示商家名称和评分,底部画一个半透明的列表卡片,和地图标记联动——这样用户可以在地图和列表两种视角间无缝切换。
中间踩了一个挺烦的坑:地图 SDK 是异步加载的,如果 API 还没初始化完成就创建实例,会各种报错。解决办法是把地图初始化包在一个 Promise 里,用动态插入 script 标签的方式加载 SDK,加载完成后再 resolve,然后整个业务只依赖这个 Promise 去拿地图实例。另外,地图标记的数量不能无节制加,不然页面会明显卡顿,超过一定数量时要考虑开启聚合或者只渲染当前视口范围内的商家。这个优化做到了之后,拖动地图的流畅度提升很明显。
5.4 从交互细节做整体体验优化
这个项目的用户群体是学生,对页面响应速度和交互流畅度非常敏感。我做了一些成本不高但收益明显的优化。搜索功能加了 300ms 防抖,避免用户每敲一个字就发一个请求;商家列表和评价列表都用分页加载,滚动到底部自动拉下一页,而不是一次性加载全部;菜品和评价图片做懒加载,没有进入视口之前不请求图片资源。评分展示组件用了半星支持,整体视觉观感比只显示整数好了很多。
还有一个容易被忽视的点:空状态设计。用户搜索不到商家、评价列表为空、收藏夹没有数据,这些场景都要有对应的插画和文案提示,而不只是一片空白。我加了一个统一的 Empty 组件,传标题和描述就行。别小看这个细节,它直接影响用户对平台专业度的判断。整个前端的打磨过程中,最深的体会是:代码实现功能只是及格线,把每个状态的交互都照顾到位,才是高完成度项目和中途半成品之间的真正分水岭。
6. 从开发到部署的完整闭环
6.1 本地开发环境的准备与版本选择
先说开发环境。后端我用的 JDK 1.8(后面可以平滑升 17),Maven 3.8+,SpringBoot 2.7.x。前端用的 Node 16+,Vite 4.x,Vue3.3。数据库用的 MySQL 8.0,之所以不用 5.7,是因为 8.0 的性能更好,对 JSON 函数、窗口函数的支持也更完善,而且本地安装和管理工具都不用额外折腾。Redis 我一开始想引入缓存,后来评估校园项目的并发量,直接查 MySQL 就足够快,反而省掉了一层复杂度。这个取舍很重要:不要为了炫技加组件,只有真正有需求才引入。
数据库初始化我用了一个init.sql脚本,包含建库、建表、插入基础演示数据三步。脚本放在项目的sql目录下,团队成员只要执行一次就能获得一致的开发环境。演示数据是手写的,模拟了 20 家校园周边商家、80 道菜品、若干用户和评价,覆盖了不同的分类和价格区间,方便开发阶段验证列表筛选和地图距离排序效果。
6.2 后端打包与前端构建部署
后端部署很简单,mvn clean package -DskipTests打成可执行 jar 包,服务器上装好 JDK 和 MySQL,直接用java -jar启动就行。有一个细节:正式部署环境的数据库连接、文件存储路径、端口号这些配置不要写死在application.yml里,用环境变量占位,启动时通过--spring.profiles.active=prod指定环境。这样切换环境只需要改环境变量,不用重新打包。
前端构建跑npm run build,产物是dist目录。我部署时用 Nginx 托管 dist 目录,配置了/api路径的反向代理指向后端服务,以及静态资源缓存策略。前后端完全分离部署,前端是纯静态文件,后端是独立进程,互不占用端口资源,将来如果要扩展集群,只需要在后端前面加一层负载均衡,前端代码一行都不用改。
6.3 上线前的基本性能检查
上线前我做了一轮基础压力测试和性能检查。用简单的并发工具模拟了 100 个并发用户同时访问商家列表和详情接口,重点观察三个指标:接口平均响应时间、数据库连接池是否被占满、慢查询是否增多。结果发现数据库连接池默认 10 个连接不够用,适当调大了HikariCP的最大连接数。另外给商家列表的默认查询加了 SQL 缓存和 MyBatis 一二级缓存(只在低频变更的数据上开启),响应时间从 80ms 降到 20ms 左右。
检查慢查询日志也是一个必修课。我开启 MySQL 的慢查询日志跑了两天,发现一个评价列表接口经常超 1 秒,EXPLAIN 一看发现 create_time 的排序走了全表扫描,补了一个联合索引后直接降到 30ms。这一步做完,整个系统的数据库压力基本可控,心里才踏实了。这个环节容易被新手忽略,但恰恰是区分“能跑”和“能用”的分界线。
7. 常见问题与排查技巧实录
7.1 联调阶段最常踩的五个坑
开发过程中我整理了一份排错手册,以下五个问题出现频率最高,基本覆盖了新手联调 80% 的报错场景。
数据库连接不上的原因,七成是 MySQL 8.0 的驱动配置问题。com.mysql.jdbc.Driver在 8.0 已废弃,要改用com.mysql.cj.jdbc.Driver,同时连接串必须加serverTimezone=Asia/Shanghai,否则因为时区问题启动直接报错。
跨域请求报错的排查顺序也要对。先看后端有没有开启 CORS,再看前端开发时有没有走 Vite proxy,最后看请求头是不是带了自定义 Header。如果开了allowCredentials(true),前端就不能用通配符*指定允许来源,必须显式列出可信域名,否则浏览器会直接拦截响应。
MyBatis 查出来的对象所有字段都为 null,这种问题十有八九是实体类字段和表字段的驼峰下划线映射没对上。在全局配置里打开map-underscore-to-camel-case: true能解决大多数场景,但如果字段名差异大,需要在 resultMap 里手动映射。
前端页面刷新后 404,是 Vue Router 开启 history 模式后的经典问题。开发环境没事,因为 Vite 有回退;生产环境 Nginx 没配置try_files就会 404。加一行配置解决:
location / { try_files $uri $uri/ /index.html; }最后一个高频问题就是图片上传成功但页面访问 404。这个就是前面提到的 SpringBoot 静态资源映射没配好,文件虽然落在磁盘上了,但 URL 没有对应到物理路径。检查application.yml里的static-locations是否包含file:前缀的绝对路径即可。
7.2 数据一致性维护的独家技巧
这个项目虽然是教学场景,但数据一致性问题一样会遇到。最典型的是用户删除评价后,商家的综合评分没有重新计算。我当时做了两个保障:一个是在 Service 层手动写一个recalculateMerchantScore方法,删除评价后调用它;另一个是定期跑一个校验脚本,对比评价表的实际评分均值和管理表里的冗余评分数值,不一致就自动修正。小项目可以靠人工脚本兜底,但养成“关键数据变更必有回归检查”的习惯是非常重要的。
另外一个容易被忽略的点是逻辑删除字段和唯一索引的冲突。如果某个字段加了唯一约束,而删除又是逻辑删除,那被删除的记录还占着唯一索引的位置,后续想重新插入同名的数据会直接报错。解决方法是唯一索引中把 deleted 字段也加进去,或者使用包含 deleted 的联合唯一索引。这个坑很隐蔽,排查起来相当费劲,分享出来帮大家提前避开。
7.3 我自己的项目推进心得
最后说说整个项目的推进方式。我个人的习惯是:不管多急的进度,绝对不允许跳过设计稿直接写代码。先用一页纸把核心业务闭环画出来,再用一页纸把数据表结构列出来,最后才进入编码环节。编码阶段尽量按模块横向推进,而不是按页面纵向推进。
模块开发的顺序我这里给一个建议,也是我最终采用的顺序:数据库设计和初始化、后端认证与用户模块、商家与菜品模块、评价与收藏模块、前端框架搭建、前端地图与列表页面、评价与个人中心、管理后台、Nginx 部署联调。按这个顺序,任何时刻系统都有一个可以展示的核心功能,阶段性成果感很强,不容易中途放弃。
如果在做类似项目时遇到那种“明明功能都能跑,但整体就感觉不对劲”的阶段,不用怀疑,大概率是交互细节和异常状态没处理到位。这时候回到用户视角,把自己当成一个真正会天天用这个平台的人,把所有入口、出口、误操作路径都走一遍,把每一个空状态、错误提示、加载中状态都补齐。做完这一轮,项目的完整度就会有一个质的飞跃。这比我当时追求多做一个功能模块的收获要大得多。