1. 项目整体设计与思路拆解
1.1 剧本杀行业背景与平台定位
先聊聊这个项目到底解决什么问题。剧本杀这几年确实火,线下门店越开越多,但绝大多数门店的预约和管理还停留在微信群里接龙、手工记表格的阶段。玩家想约一场本,得先加群、翻聊天记录、找客服确认场次,体验很差;店家想管理剧本库存、统计场次上座率,也缺乏趁手的工具。所以做一个“剧本杀服务平台”,核心就是把拼车、预约、剧本展示、场次管理这些线下流程搬到线上,让玩家能快速找到想玩的本、约上合适的场次,也让店家能高效管理门店运营。
这个项目用SpringBoot + Vue来做前后端分离,在同类毕设或个人项目中是非常主流且稳妥的选型。SpringBoot负责提供RESTful接口,处理业务逻辑和数据持久化;Vue负责页面交互和接口调用,两者通过JSON格式的数据通信。相比传统的JSP + Servlet方案,前后端分离的架构更贴近企业真实开发场景,也方便后续扩展移动端或小程序端——只要复用后端接口即可。对于想找工作或考研复试的同学来说,这个项目完整覆盖了“用户登录鉴权、核心业务CRUD、预约状态流转、后台管理”这些高频考点,写在简历上是能拿出来讲的。
1.2 技术选型:为什么是SpringBoot + Vue,而不是其他组合
很多人问过我,为什么选SpringBoot + Vue,而不是SpringBoot + Thymeleaf,或者用若依这类快速开发框架?我分几点说清楚。
第一,SpringBoot解决的是后端“配置地狱”问题。传统SSM整合要写大量的XML配置,SpringBoot通过自动配置和起步依赖,把数据源、事务、Web容器这些底层细节封装好,开发时只需要在application.yml里配置数据源、端口等必要参数即可。一个简单的剧本列表接口,从创建项目到跑通,十几分钟就能完成。这对个人开发者非常友好,能把精力集中在业务代码本身。
第二,Vue在前端生态里是渐进式的,上手曲线平滑。项目不需要一开始就用TypeScript、Pinia这些进阶方案,直接从选项式API + Vue Router + Axios起步,就能完成全部页面功能。而且Vue的组件化开发模式天然适合“剧本卡片”“场次列表”“预约弹窗”这类高复用界面。我后面会在前端模块里详细拆解组件怎么划分。
第三,前后端分离带来的部署灵活性。后端打成jar包跑在服务器上,前端打包成静态资源用Nginx托管,两者互不干扰。本地开发时通过代理转发解决跨域,线上部署时通过Nginx统一入口,既简洁又不容易出问题。
至于为什么不直接用若依这类脚手架,我的理解是:毕设或项目实战的价值在于你把每个环节都亲手实现一遍,能讲清楚JWT鉴权怎么做的、拦截器拦截了什么、订单状态怎么流转的。直接用脚手架虽然快,但面试官一问底层细节就容易露馅。当然,若依的代码结构值得参考,尤其是权限管理部分,后面我会提到可以借鉴的思路。
1.3 功能模块设计与数据库建模思路
平台的核心用户是两类:C端玩家和B端店家/管理员。围绕这两类角色,功能模块可以拆成六个主要部分:
| 模块 | 功能说明 | 主要角色 |
|---|---|---|
| 用户认证 | 注册、登录、JWT签发与校验、用户信息维护 | 游客、玩家 |
| 剧本管理 | 剧本列表、详情、类型筛选、搜索 | 玩家、管理员 |
| 门店与场次管理 | 门店信息、场次排期、座位数管理 | 玩家、店家 |
| 预约拼车 | 创建预约、加入拼车、取消预约、状态流转 | 玩家 |
| 订单管理 | 订单列表、订单状态跟踪、结算信息 | 玩家、管理员 |
| 后台管理 | 用户管理、剧本上下架、数据统计 | 管理员 |
数据库建模是这一步的重头戏。我的建议是不要一上来就追求字段多,先保证核心表的关系清晰。平台最少需要这几张表:
user:用户表,字段包括id、username、password(BCrypt加密存储)、nickname、phone、role(区分玩家/管理员)、create_time。script:剧本表,字段包括id、name、type(还原本/阵营本/情感本等)、difficulty、duration、min_players、max_players、price、description、cover_url、status(上架/下架)。store:门店表,字段包括id、name、address、phone、business_hours。session:场次表(也有叫arrangement的),核心是脚本–门店–时间三个维度的绑定,字段包括id、script_id、store_id、start_time、end_time、max_seats、booked_seats、status。appointment:预约/拼车表,字段包括id、session_id、user_id、seats、status(待成团/已成团/已完成/已取消)、create_time。
这里有一个设计细节值得注意:appointment表和session表之间既要记录预约行为,又要维护场次剩余的座位数。我在开发时采用的是“预约主表 + 座位数字段冗余”的方式,每次新增预约时校验并更新对应session的booked_seats。虽然牺牲了一点规范化,但查询效率更高,也不用频繁做聚合计算。后文我会专门讲这个并发控制问题。
2. 核心技术细节解析与实操要点
2.1 后端分层架构:从Controller到Mapper的职责划分
后端代码我习惯按标准的四层结构组织:Controller(接口层)、Service(业务层)、Mapper(数据访问层)、Entity/DTO(模型层)。这样做的好处是职责单一,出了问题能快速定位是参数校验的锅、业务逻辑的锅,还是SQL的锅。
先看一个查询剧本列表的接口设计:
@RestController @RequestMapping("/api/script") public class ScriptController { @Resource private ScriptService scriptService; @GetMapping("/list") public Result<List<ScriptVO>> list(@RequestParam(required = false) String type, @RequestParam(required = false) String keyword, @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { return Result.success(scriptService.getScriptList(type, keyword, pageNum, pageSize)); } }Controller层只负责接收参数、调用Service、包装返回结果。具体的参数校验可以交给@Validated注解配合实体类上的@NotBlank等校验规则来做,而不是在Controller里写一堆if-else。这一点在代码评审时是加分项。
Service层承载核心业务逻辑。比如预约模块里的“创建预约”方法,就涉及好几步操作:根据sessionId查出场次信息、检查当前场次是否还有余座、检查当前用户是否已经预约过该场次、生成预约记录、更新场次已订座位数。每一步都需要在Service层明确编排,而不是把业务逻辑散落在Controller或Mapper里。
再往下是Mapper层。我用的持久层框架是MyBatis-Plus,它最强的地方是提供了内置的CRUD方法,单表操作基本不用写SQL,直接调用selectById、insert等方法即可。复杂的多表关联查询再配合@Select注解或XML文件编写SQL。例如查询剧本详情时需要连带返回门店名称和平均评分,就可以写一条联表SQL处理。
2.2 JWT鉴权与拦截器:登录状态怎么安全地保持
前后端分离场景下,Session机制并不好用,因为前端和后端不在同一个域下,SessionId的传递和共享比较麻烦。实际项目中更普遍的做法是JWT(JSON Web Token)。
JWT的原理可以理解成一张“带签名的通行证”。用户登录成功后,后端把用户id、角色等信息放进Token的载荷部分,用密钥签名生成一串字符串返回给前端。前端每次请求时把这个Token放到请求头Authorization字段里,后端拦截器校验签名和有效期,通过后放行请求,并从Token中解析出当前用户信息。
登录接口核心代码逻辑:
public String login(LoginDTO dto) { User user = userMapper.selectByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { throw new BusinessException("用户名或密码错误"); } // 生成JWT,有效期设置为24小时 String token = JwtUtil.createToken(user.getId(), user.getRole()); return token; }密码存储一定要用BCrypt加密,千万不能明文存。BCrypt每次加密的结果是随机的,但checkpw方法可以校验原文是否匹配,安全性比MD5加盐要好很多。我见过不少项目还用MD5,这在简历上写出来会被面试官追问到很难看。
JWT拦截器的核心逻辑:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } return true; } }拦截器在SpringBoot中注册时还需要排除掉登录接口、静态资源路径等白名单。我建议使用一个自定义的WebMvcConfigurer来配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/user/login", "/api/user/register"); } }这里有个很关键的坑:拦截器放行路径的写法。如果你把/api/**都拦截了,却忘了排除登录接口,那么登录请求也会被拦截,导致前端调登录接口直接报401,还查不出原因。我第一次整合时就踩了这个坑,排查了半天才发现是路径配置的问题。
2.3 Vue前端架构:组件化划分与路由设计
Vue前端的目录结构我按照功能来组织,而不是按页面来堆砌。目录结构如下:
src/ api/ // 接口请求封装 script.js user.js appointment.js assets/ // 静态资源 components/ // 通用组件 NavHeader.vue ScriptCard.vue router/ // 路由配置 index.js views/ // 页面组件 home/HomeView.vue script/ScriptListView.vue script/ScriptDetailView.vue appointment/AppointmentView.vue user/LoginView.vue user/RegisterView.vue admin/AdminDashboard.vue store/ // 状态管理(可选,本项目可用可不用) user.js utils/ request.js // axios封装组件化设计要遵循“高内聚、低耦合”的原则。我把ScriptCard抽成通用组件,因为无论首页推荐、剧本列表还是搜索结果页,都需要展示剧本的封面、名称、类型、难度、价格等信息。只需要把剧本对象通过props传进去,组件内部负责展示即可。
路由方面,我采用了Vue Router的createWebHistory模式,并在路由守卫里处理登录控制:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });requiresAuth标记需要登录才能访问的页面,比如“我的预约”页面。游客未登录时访问会被重定向到登录页,并带上跳转前路径,登录成功后可以自动跳回原页面,这个体验细节很实用。
2.4 Axios封装与前后端联调:接口对接的标准姿势
前后端分离项目最容易出问题的环节就是接口联调,所以我建议把Axios统一封装在一个request.js里,统一处理请求头携带Token、响应拦截、错误提示。
import axios from 'axios'; import { message } from 'ant-design-vue'; import router from '@/router'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); // 请求拦截器:自动携带Token request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = token; } return config; }); // 响应拦截器:统一处理错误 request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { message.error('登录已过期,请重新登录'); localStorage.removeItem('token'); router.push('/login'); } else { message.error(error.response?.data?.message || '请求失败'); } return Promise.reject(error); } ); export default request;这里有一个设计要注意的地方:后端返回的Result结构统一为{ code, message, data },所以响应拦截器直接return response.data即可,业务代码里只需要关注res.code === 200就能拿到res.data,不用每次写response.data.data这种冗长代码。
开发阶段的跨域问题,我在vue.config.js里配置了代理:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };这样前端的请求都走相对路径/api/...,由开发服务器代理到后端的8080端口,避开了浏览器同源策略的限制。线上部署时则由Nginx统一处理静态资源转发和API反向代理,我在第4节会详细展开。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
项目初始化阶段,后端需要准备好Java 8+、Maven 3.6+、MySQL 5.7+(推荐8.0),前端需要Node.js 16+和npm。这一步没什么捷径,但我有一个实际经验:Node版本不要盲目追新。Vue CLI项目在Node 18下可能存在依赖兼容问题,而Node 20在某些老项目中会更明显。如果遇到安装依赖报错,先用Node 16的LTS版本试试,很多问题能迎刃而解。
后端初始化时,我推荐直接去Spring Initializr官网生成基础工程,选择Spring Web、MyBatis-Plus、MySQL Driver、Lombok这些依赖。这里有个小建议:Lombok确实能减少getter/setter代码,但用了IDE编译报错时,记得先检查是不是没装Lombok插件。这个问题特别常见,尤其是用IDEA的新手。
前端初始化用Vue CLI或Vite都可以。Vue CLI的生态成熟、资料多,出问题了容易查到解决方案;Vite启动速度快,但部分老组件库可能存在兼容问题。如果你是第一次做完整项目,我建议用Vue CLI,稳妥优先。
3.2 剧本管理模块:前后端完整实现
剧本管理是最能体现CRUD基本功的模块,从后端到前端走通一遍,其他模块基本就心里有底了。
后端部分,实体类对应的数据库表:
@Data @TableName("script") public class Script { @TableId(type = IdType.AUTO) private Integer id; private String name; private String type; private String difficulty; private Integer duration; private Integer minPlayers; private Integer maxPlayers; private BigDecimal price; private String description; private String coverUrl; private Integer status; private LocalDateTime createTime; }@TableName注解用来指明实体类对应的数据库表名,@TableId用来标注主键。这些是MyBatis-Plus的约定,只要实体类和表的字段对应上了,单表操作全都可以靠内置方法解决。
Service层写了剧本新增和分页查询两个核心方法:
public Page<Script> getScriptList(String type, String keyword, Integer pageNum, Integer pageSize) { LambdaQueryWrapper<Script> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(type)) { wrapper.eq(Script::getType, type); } if (StringUtils.hasText(keyword)) { wrapper.like(Script::getName, keyword) .or().like(Script::getDescription, keyword); } wrapper.orderByDesc(Script::getCreateTime); return scriptMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); }这里用到了MyBatis-Plus的LambdaQueryWrapper,它的好处是类型安全。如果你把字段名写成字符串,重构实体类字段时很容易漏改,Lambda表达式可以尽量避免这个问题。
前端的剧本列表页,就是一个经典的“搜索条件 + 卡片列表 + 分页”布局。搜索区域用el-form内联布局,剧本类型用el-select下拉选择,关键字用el-input输入,门店或难度等过滤条件按需增加。列表区域用el-card循环展示,点击卡片跳转到详情页。
详情页要调用详情接口:
@GetMapping("/detail/{id}") public Result<ScriptDetailVO> detail(@PathVariable Integer id) { ScriptDetailVO vo = scriptService.getDetailById(id); return Result.success(vo); }详情接口返回的不只是剧本基础信息,还包括关联的门店场次列表,因此VO里会嵌套一个场次列表字段。后端用一个ScriptDetailVO接收多表查询结果,前端拿到后分开渲染剧本信息区和可选场次区。对这种“主表 + 子表”的场景,前端一定要处理好子表为空时的空状态,避免渲染报错。
3.3 预约拼车模块:并发控制与状态流转
预约模块是整个项目里业务复杂度最高的部分,因为它涉及到并发场景下的数据一致性。举个实际场景:某个热门剧本的场次只剩下1个座位,这时候A和B两个玩家同时点击预约,谁能成功?如果代码逻辑是先查询booked_seats < max_seats,然后执行insert,再更新booked_seats,存在一个典型的时间窗口——两个请求同时通过了查询,然后都执行了insert,最后座位数就超卖了。
解决超卖问题有几种方案,我按由易到难排序:
第一个是乐观锁。在session表增加一个version版本号字段,更新座位数时通过SQL条件WHERE id = ? AND version = ?来保证只有版本号匹配才能更新成功,并且每次更新版本号加1。如果更新影响行数为0,说明版本冲突,返回“手速慢了,座位已被抢走”。这是性能和复杂度都比较均衡的方案。
@Update("UPDATE session SET booked_seats = booked_seats + #{seats}, version = version + 1 " + "WHERE id = #{sessionId} AND version = #{version} " + "AND booked_seats + #{seats} <= max_seats") int lockSeats(@Param("sessionId") Integer sessionId, @Param("seats") Integer seats, @Param("version") Integer version);第二个是数据库悲观锁,使用SELECT ... FOR UPDATE锁定行记录,事务结束后释放锁。这种方式最保险,但并发性能略差,且需要谨慎控制事务边界,否则容易导致长事务锁表。
第三个是分布式锁,用Redis的SETNX实现。这属于锦上添花的方案,单机部署的项目其实用不到,但面试时可以提一下你了解。
预约状态流转也值得花心思设计。我定义的预约状态有四种:待成团、已成团、已完成、已取消。当某个场次预约人数达到最低开本人数时,系统把预约状态改成“已成团”;场次时间结束后,定时任务或管理员手动操作将“已成团”改成“已完成”。虽然业务逻辑不复杂,但前后端都要对这个状态做对应处理——前端展示状态标签时要有不同的颜色和文案,后端接口要根据不同状态判断允许的操作(比如已成团后玩家不能再取消)。
3.4 后台管理:文件上传与数据统计
后台管理模块给管理员提供剧本上下架、用户管理、场次管理、数据看板等功能。这个模块的技术难度主要在文件上传和图表渲染上。
剧本封面上传是一个高频需求。前端用el-upload组件,后端提供一个接收MultipartFile的接口,将文件保存到服务器本地或者对象存储服务中。
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String extName = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFileName = UUID.randomUUID() + extName; String datePath = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); File dir = new File(uploadPath + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, newFileName)); String url = "/files/" + datePath + "/" + newFileName; return Result.success(url); }文件上传有几个需要特别注意的细节:
- 文件名一定要重命名,不能直接用原始文件名,否则会遇到中文乱码、路径穿越、文件名冲突等问题。
- 文件保存路径建议用日期分目录,便于后期清理和排查。
- 上传文件时要在Nginx或后端配置文件访问映射。比如后端把文件放在了
/data/files目录,Nginx需要配置location /files/ { alias /data/files/; }才能访问到图片。 - 限制上传文件大小和类型,防止被别人传恶意脚本文件上去。
数据统计方面,可以用el-card展示核心指标(注册用户数、今日预约数、剧本总数、门店总数),用Apache ECharts的折线图展示近7天预约趋势,用饼图展示剧本类型分布。后端统计接口主要就是几个GROUP BY查询,再按日期补0填充,这部分代码不难,但要注意SQL中的日期处理要兼容数据库类型。
3.5 全流程部署:从jar包到Nginx静态资源
开发完项目后,部署上线是一个必经环节,也经常被新手忽略。我的部署方案是:后端jar包运行在服务器上(比如8000端口),前端打包后的dist目录交给Nginx托管,Nginx监听80端口,统一对外提供服务,同时把/api开头的请求反向代理到后端服务。
Nginx关键配置:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/script-platform/dist; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 上传文件访问 location /files/ { alias /data/script-platform/files/; } }这里的try_files $uri $uri/ /index.html;非常关键。Vue使用History模式时,如果用户直接在浏览器地址栏输入某个子路由地址(如/script/2),服务器上其实没有这个物理路径,Nginx需要把所有路径都回退到index.html,由前端路由接管。如果不加这一行,刷新页面就会报404。
后端打jar包的命令:
mvn clean package -DskipTests然后通过nohup java -jar script-platform.jar > app.log 2>&1 &启动服务。到这里,一个完整的前后端分离项目就算真正落地了。
4. 常见问题与排查技巧实录
4.1 SpringBoot版本与依赖冲突问题
SpringBoot版本的选择直接影响后续依赖的可用性。常见的坑是SpringBoot 3.x和SpringBoot 2.x在javax与jakarta命名空间上的变化。SpringBoot 3.x要求JDK 17+,并且把javax.servlet替换成了jakarta.servlet,很多老教程里的代码在3.x下会直接编译失败。如果用JDK 8,那只能选择SpringBoot 2.7.x及以下版本。所以我的建议是:先确定JDK版本,再选SpringBoot版本,最后根据SpringBoot版本选配套的MyBatis-Plus和SpringCloud版本,顺序不能乱。
依赖冲突的另一个高发点是pagehelper和mybatis-plus同时引入,两者都会操作MyBatis的插件机制,容易出现分页查询不稳定或直接报错。如果用了MyBatis-Plus,就不要引入PageHelper,分页直接用selectPage即可。同理,不要同时引入mysql-connector-java和mysql-connector-j两个坐标,版本不一致时会报莫名其妙的驱动类错误。
排查这类问题,先看启动日志里报错的第一行堆栈,而不是看最后一行。第一行通常会告诉你“jar包版本冲突”或“找不到某个类”,根据提示在pom.xml中排除传递依赖或统一版本号。
4.2 Vue依赖安装与启动问题
前端项目最常见的报错是Module not found: Error: Can't resolve 'element-plus',这种问题一般有三个原因:一是没有执行npm install,二是package.json里没有声明这个依赖,三是node_modules目录损坏。解决办法依次是:确认package.json依赖声明、删除node_modules目录后重新执行npm install、检查npm镜像源是否正常。
还有一类很典型的问题是el-message使用时提示未定义。在Element Plus中,ElMessage是一个独立的API,使用前需要先引入:
import { ElMessage } from 'element-plus';如果你是通过插件方式全局引入Element Plus,那么在组件里必须显式import。但如果是用自动导入插件(如unplugin-vue-components + unplugin-auto-import),就要额外配置AutoImport的resolvers,否则ElMessage还是找不到。这个坑比较隐蔽,很多新手在全局引入了Element Plus后仍然报错,就是因为自动导入插件把组件按需导入了,但API级别的方法没有被自动注册。
4.3 前后端联调中的跨域与Cookie问题
联调阶段的跨域问题很常见。虽然我前面提到在vue.config.js配置了开发代理,但有些同学的代码里把axios的baseURL写成了绝对地址(如http://localhost:8080),这样的话代理配置就不生效了,因为代理只匹配相对路径/api开头的请求。正确的做法是:baseURL统一写成/api,由代理转发,不要写死IP地址。
如果你在生产环境遇到了跨域,最稳妥的方案是交给Nginx处理,而不是在后端加@CrossOrigin注解或CORS全局配置。后端放开CORS看似方便,但会导致所有域名都能调用你的接口,存在安全隐患。比较好的做法是Nginx只允许指定域名访问,后端保持接口纯净。
4.4 敏感信息泄露与安全加固
最后说一个很多人忽视的安全问题。SpringBoot项目如果以默认配置启动,Actuator监控端点可能暴露系统信息、环境变量,甚至导致堆内存信息被不明访问者下载。如果你的pom.xml里引入了spring-boot-starter-actuator,务必在application.yml中限制端点暴露范围,或者干脆去掉这个依赖。
再一个就是数据库密码、Redis密码等敏感配置不能明文写在application.yml里。可以用Jasypt对配置项做加密,在配置文件中使用ENC(加密串)占位,运行时由Jasypt解密。比如将数据库密码加密后配置:
spring: datasource: url: jdbc:mysql://localhost:3306/script_platform username: root password: ENC(加密后的密文)然后在启动类或配置类上启用Jasypt:
@SpringBootApplication @EnableEncryptableProperties public class ScriptPlatformApplication { public static void main(String[] args) { System.setProperty("jasypt.encryptor.password", "你的密钥"); SpringApplication.run(ScriptPlatformApplication.class, args); } }密钥可以通过启动命令参数传入,避免写死在代码里。这个处理在简历上可以作为一个安全亮点来写,面试官会认为你有安全意识。
这个项目做完之后,最大的感受是:剧本杀服务平台虽然业务不算复杂,但麻雀虽小五脏俱全,从前端交互到后端并发控制都涉及了,做完一遍对整个前后端分离开发流程会有一个非常完整的认知。如果你打算在这个基础上继续扩展,可以试试增加微信小程序端,或者引入Redis缓存热点剧本数据、用消息队列处理预约通知,往这些方向深化,项目质量会有明显的提升。我个人在实际操作中的体会是,不要一上来就追求大而全,先把核心链路跑通,再逐步迭代,这才是做项目最踏实的节奏。