BrewDaily 全栈开发实录
Vanilla JS + SSM (Spring + Spring MVC + MyBatis) 全栈 Web 应用开发记录
基于实际项目 /Users/ann/Desktop/期末项目/brewdaily · 2026年6月
目录
一、项目概述
BrewDaily 是一款专为手冲咖啡爱好者设计的 Web 应用,涵盖冲煮计时、水粉比计算、冲煮记录管理、方案管理等功能。项目采用前后端分离架构,前端为 Vanilla JS SPA(无框架),后端为 SSM(Spring + Spring MVC + MyBatis)。
以下从实际代码出发,记录从零搭建该项目的技术决策、实现细节和踩坑经验。
项目基本信息:
// pom.xml(实际) <groupId>com.brewdaily</groupId> <artifactId>brewdaily-backend</artifactId> <version>1.1.0</version> <packaging>war</packaging> // 技术栈版本 Spring Framework 6.1.6 MyBatis 3.5.16 Druid 1.2.23 MySQL 8.0+ Jackson 2.17.1 Java 17 (Dragonwell)
二、技术栈选型
项目技术选型基于两个原则:一是课程要求(SSM 框架),二是实际体验优先(前端无框架)。
后端核心:
- Spring Framework 6.1.6 — IoC 容器、声明式事务管理
- Spring MVC — RESTful API,@RestController + @RequestBody/@PathVariable
- MyBatis 3.5.16 — 数据持久化,XML Mapper 映射
- Druid 1.2.23 — 数据库连接池,监控功能
- PageHelper 6.1.0 — 物理分页,避免内存分页
- BCrypt — 密码加密存储
- Jackson 2.17.1 — JSON 序列化/反序列化
前端:
- Vanilla JavaScript (ES6+) — 无框架依赖,零构建步骤
- HTML5 + CSS3 — 响应式设计,移动端优先
- PWA — Service Worker + manifest.json,可安装到主屏幕
- localStorage — 离线数据回退
部署:
- Tomcat 10 — Servlet 6.0 容器,部署 WAR 包
- Nginx — 反向代理 /api/ 到后端
- MySQL 8.0 — 数据持久化存储
选择 Vanilla JS 而非 Vue/React 的理由:手冲计时器对实时性要求较高(毫秒级更新),无框架方案避免虚拟 DOM 开销。同时,作为一个工具型应用,页面交互以计时器计时和表单操作为主,状态管理简单,不需要框架级的响应式系统。
三、后端架构设计(SSM)
SSM 后端采用经典的分层架构:Controller → Service → DAO → MyBatis Mapper。
项目结构:
com.brewdaily/ ├── aspect/LogAspect.java # AOP 日志切面 ├── config/ # Spring 配置 │ ├── AppConfig.java # 根容器配置 │ ├── DruidConfig.java # Druid 连接池 │ ├── MyBatisConfig.java # MyBatis + PageHelper │ ├── WebAppInitializer.java # Servlet 3.0+ 初始化 │ └── WebMvcConfig.java # MVC 配置 + 拦截器 ├── controller/ # REST API 控制器 │ ├── AuthController.java # 登录/注册/登出 │ ├── BrewController.java # 冲煮记录 CRUD + 统计 │ ├── RecipeController.java # 冲煮方案管理 │ └── SystemController.java # 系统状态/DDL ├── dao/ # MyBatis Mapper │ ├── BrewMapper.java │ ├── RecipeMapper.java │ └── UserMapper.java ├── dto/ # 数据传输对象 │ ├── LoginDTO.java │ ├── PageResult.java │ ├── RegisterDTO.java │ └── Result.java # 统一响应包装 ├── entity/ # 实体类 │ ├── BrewRecord.java │ ├── Recipe.java │ ├── RecipeStage.java │ └── User.java ├── exception/ # 异常体系 │ ├── BusinessException.java │ ├── GlobalExceptionHandler.java │ └── UnauthorizedException.java ├── interceptor/AuthInterceptor.java # 登录拦截 ├── service/impl/ # 业务实现 │ ├── BrewServiceImpl.java │ ├── RecipeServiceImpl.java │ └── UserServiceImpl.java └── util/PasswordUtil.java # BCrypt 密码工具
几个关键设计:
1. 统一响应封装
所有 API 返回统一使用 Result<T> 包装,前端基于 code 字段判断成功/失败:
{
"code": 200, // 200=成功 401=未登录 403=无权限 404=未找到
"msg": "success",
"result": { ... } // 实际数据
}
2. 登录拦截器
AuthInterceptor 拦截 /api/brews/ 和 /api/recipes/ 下的请求,检查 Session 中的登录状态。没登录就返回 401,前端收到后弹出登录框。
3. 分页查询
冲煮记录列表使用 PageHelper 实现物理分页,避免一次性加载大量数据。前端传入 page 和 pageSize 参数,后端返回总记录数和当前页数据。
4. AOP 日志
LogAspect 使用 @Around 注解,自动记录所有 controller 方法的调用参数、执行耗时和返回结果。开发时便于调试,生产时可用于排障。
四、前端模块化设计
前端采用模块化设计,共 9 个独立模块,通过 IIFE(立即执行函数)暴露到 window 对象:
js/ ├── api.js # API 请求封装 + Token 管理 + 离线回退 ├── app.js # 主应用:路由、登录对话框、版本管理 ├── calculator.js # 水粉比计算器(g/oz 切换) ├── data.js # 数据层:预设方案、存储 Key 常量 ├── delight.js # 彩蛋引擎:微交互、庆祝动画、成就系统 ├── icons.js # SVG 图标系统 ├── profile.js # 个人中心:统计、冲煮记录、头像 ├── recipe-editor.js # 方案编辑器 ├── recipe-import.js # 批量导入(CSV 解析 + 预览 + 确认) └── timer.js # 冲煮计时器核心
模块间通信策略:
- 共享数据通过 window 对象(如 window.ApiModule、window.ProfileModule)
- 状态存储在 localStorage(离线可用),后端 API 返回时同步
- 模块间直接调用对方暴露的 API 方法(如 timer 完成时调用 delight.js 的庆祝动画)
单例控制器模式:
为避免多个计时器实例或事件绑定冲突,每个模块使用单例 + init() 模式——只在首次调用时初始化 DOM 绑定,后续只更新数据:
// timer.js 的实际 init 模式
var Timer = (function() {
var initialized = false;
function init() {
if (initialized) return;
initialized = true;
// 绑定事件、创建 DOM 等一次性操作
}
function start() { /* ... */ }
return { init: init, start: start };
})();
离线回退策略:
api.js 实现了双层数据策略:优先请求后端 API,请求失败时自动回退到 localStorage。这意味着即使后端服务暂时不可用,计时器和计算器等核心功能依然可用。
五、API 设计与交互
后端提供 RESTful API,共 4 个模块:
# 认证
POST /api/auth/login # 登录
POST /api/auth/register # 注册
POST /api/auth/logout # 登出
GET /api/auth/me # 获取当前用户
# 冲煮记录
GET /api/brews # 分页查询(支持 sort 排序)
POST /api/brews # 创建记录
GET /api/brews/stats # 统计(总杯数、连续天数等)
DELETE /api/brews/{id} # 删除记录
# 冲煮方案
GET /api/recipes # 查询我的方案
POST /api/recipes # 创建方案
PUT /api/recipes/{id} # 更新方案
DELETE /api/recipes/{id} # 删除方案
# 系统
GET /api/ping # 健康检查
前端使用 fetch API 发送请求,Cookie 自动携带 Session ID。生产环境通过 Nginx 反向代理 /api/ → Java 后端 8080 端口。
六、PWA 与离线支持
BrewDaily 是一个完整的 PWA,支持离线使用和安装到主屏幕。
Service Worker:
sw.js 缓存了应用的核心资源(HTML、CSS、JS、图标),缓存名使用版本号管理,版本更新时自动清理旧缓存。
缓存策略:
- 核心资源(index.html、app.js 等):Install 时预缓存
- 图标:Install 时预缓存
- API 响应:Network First(优先网络,失败时回退缓存)
缓存版本管理:
每次发版更新 sw.js 中的 CACHE_VERSION,activate 事件中删除所有旧版本缓存。
七、彩蛋系统设计
彩蛋系统是项目中比较有趣的一部分,记录了冲煮过程中的各种成就里程碑。
彩蛋触发条件:
- 累计冲煮达到特定杯数(1杯、5杯、10杯、25杯、50杯、100杯)
- 连续打卡达到一定天数
每个彩蛋包含:
- 一段趣味描述(与咖啡相关的冷知识或调侃)
- 解锁时的庆祝动画
- 冲煮完成时的微交互(咖啡滴落动画、冒热气效果)
彩蛋进度存储于 localStorage,用户清除缓存后会重置。
八、构建与部署
构建流程:
后端使用 Maven 构建 WAR 包:
mvn clean package # → target/brewdaily-backend-1.1.0.war
部署方式:
生产环境运行方式(直接 Tomcat 启动):
WAR 包部署到 /usr/local/tomcat10/webapps/ Nginx 反向代理 /api/ → http://127.0.0.1:8080/brewdaily/api/
Nginx 配置(取自 /usr/local/nginx/conf/vhost/coffee.conf):
location /api/ {
proxy_pass http://127.0.0.1:8080/brewdaily/api/;
proxy_cookie_path /brewdaily /;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / { try_files $uri $uri/ /index.html; }
前端静态文件直接由 Nginx 托管,Service Worker 和 index.html 不走缓存,JS/CSS 开发环境不缓存,生产环境有版本控制。
九、踩坑记录与经验总结
9.1 brewed_at 字段默认值被覆盖
问题:冲煮记录的日期始终为 NULL,统计功能全部失效。原因是 MyBatis 的 INSERT 语句显式写入了 brewed_at = NULL,覆盖了数据库列的 DEFAULT CURRENT_TIMESTAMP。
解决:在 BrewServiceImpl.create() 中加入自动设置逻辑——如果记录中 brewed_at 为 null,则设置为 LocalDateTime.now()。
9.2 ratio 字段类型不匹配
问题:MySQL 中 brew_record.ratio 字段类型为 DECIMAL(3,1),无法存储 “1:16” 格式的字符串。
解决:执行 ALTER TABLE brew_record MODIFY COLUMN ratio VARCHAR(20),将数字类型改为字符串类型。
9.3 生产环境 Cookie 跨域问题
问题:开发时前端与后端同源(localhost:8080),Session Cookie 自动携带。部署后前端(coffee.ann.hi.cn)与后端(同域不同端口)跨域,Cookie 丢失。
解决:通过 Nginx 同源反向代理解决,前端访问 /api/ 时 nginx 转发到 8080 端口,浏览器认为是同源请求,Cookie 可以正常携带。
9.4 PJAX/SPA 路由与后退按钮
问题:SPA 页面切换使用 JavaScript 控制显示/隐藏,浏览器后退按钮不支持页面级导航。
解决:页面切换时更新 URL hash(#timer、#calculator、#profile),监听 hashchange 事件恢复对应页面状态。
9.5 离线回退导致的数据不一致
问题:启用了 localStorage 离线回退,用户离线时写入的数据无法同步到服务器。
解决:仅对读操作启用离线缓存,写操作(创建冲煮记录、保存方案等)必须在线完成。离线缓存的数据仅作为只读回退,不提供离线写入。
9.6 设计上的取舍
作为期末作业,BrewDaily 在设计和实现上做了不少取舍:
- 无前端框架:减少了构建步骤和依赖管理,但也意味着没有组件化和虚拟 DOM
- Session 而非 JWT:课程要求使用 Session 认证,Spring Session 管理用户状态更符合教学目的
- WAR 部署而非 Docker:虽然项目中有 Dockerfile 和 docker-compose.yml,但生产环境最终使用传统 WAR 包部署
每个取舍背后都有具体的理由,这大概就是全栈开发最有趣的地方——技术没有绝对的好坏,只有合不合适的场景。