“这个项目从立项到跑通第一个接口,我整整记了两个星期的账。”这是我的研发日记第一篇。项目是给一家小型物流公司做一套业务管理系统,团队五个人,周期两个多月,需求还在变。作为技术负责人,我选择了直接用开发日志的方式记录整个过程——从技术选型到工程搭建,从联调流程到团队分工,每一步都留档,方便后面复盘,也方便任何一个中途加入的新人快速上手。
这篇内容不打算做成教程式的“一步步教学”,而是尽量还原一次真实的“从0到1”:我们怎么定技术栈,为什么这么定;怎么把前后端工程从空目录铺到能跑通接口;五个人怎么拆活、怎么协作;以及那些文档里不会写、只有踩过才知道的坑。适合正在带小团队、准备启动一个前后端分离项目的同学参考,哪怕你们的技术栈不完全一样,踩坑思路和协作机制也基本通用。
1. 项目启动前最花时间的环节:技术选型与边界划定
很多人启动项目恨不得第一天就把代码拉起来跑,但我的习惯恰恰相反:技术选型和工程边界划定,值得花掉整个项目周期的10%时间。这个环节一旦草率,后面所有开发节奏都会被拖累。
1.1 后端选型:为什么选Spring Boot 3.x而不是“更新”的框架
我们这个团队里,后端开发一共三个人,全部有Java背景,最熟悉的技术栈就是Spring Boot。当时我也认真考虑过用Go或者Node.js,但从团队基因来看不划算——选一个大家没做过的东西,至少有两周学习成本,而且踩坑时连个能商量的人都没有。
所以在后端上,我们的组合是:
- JDK 17 + Spring Boot 3.2.x
- MyBatis-Plus作为持久层框架
- PostgreSQL 15作为主数据库
- Flyway做数据库版本管理
选Spring Boot 3.x而不是2.x,是因为既然是新项目,没有历史包袱,那肯定直接用最新的稳定主线。Spring Boot 3基于Spring Framework 6和Jakarta EE 9+,性能、安全补丁、生态适配都更健全。JDK 17是LTS版本,生产环境用着放心。
选MyBatis-Plus而不是Spring Data JPA,很大程度是团队习惯问题。大家都写过MyBatis XML,对动态SQL的掌控力更强,而MyBatis-Plus又补足了单表CRUD的短板,不用写重复的Mapper XML,代码量少了一大截。尤其管理类系统,查询条件复杂、多表关联多,动态SQL是刚需。
PostgreSQL是我自己“带私货”选的。比MySQL的优势是:原生JSONB类型、CTE(公共表表达式)、更多的索引类型、以及扩展机制。这套业务系统里有一部分复杂的统计报表需求,用PostgreSQL的窗口函数和CTE写起来比MySQL顺手太多。
1.2 前端选型:Vue 3 + TypeScript是团队最优解
前端团队两个人,一个Vue经验多,一个React和Vue都写过但更偏Vue。管理后台类系统,Vue 3的组合式API写起来很舒服,配合TypeScript,维护成本和中大型页面的可读性都很有保障。
具体技术栈:
- Vue 3.x + TypeScript
- Vite作为构建工具
- Pinia做状态管理
- Vue Router 4做路由
- Element Plus做UI组件库
选择Vite不是因为它“流行”,而是它基于ESM的开发服务器冷启动快得离谱。我们用Webpack 4的老项目,冷启动可能要等二三十秒,换成Vite后基本是秒开。前端开发的反馈循环短了一大截,团队幸福感直接上升。
UI组件库选Element Plus,和Vue 3的生态绑定最紧,表格、表单、弹窗等中后台场景组件齐全。Ant Design Vue的React血统太重,用起来总感觉不够“Vue原生”。我们不做C端营销页,不需要炫酷的动画,Element Plus的中规中矩反而是优点。
1.3 不要过度设计:单体应用就够了
这里要特别说一句:很多人上来就规划微服务,这其实是个陷阱。我们的业务量级、团队规模、交付周期,都不支持微服务的落地。微服务解决的是“大规模团队并行开发”和“独立水平扩展”的问题,五个人两个月交付的管理系统,微服务只会让分布式事务、服务发现、配置中心这些复杂度压垮整个项目。
所以我们选了单体应用加模块化分包,将来要拆微服务,按业务模块把代码抽出去就行。数据库层面也是同一个库,但不同业务模块的表前缀分开、连接池隔离,这样后续拆分的时候动作最小。
基础设施也一样,没用Kubernetes,就用Docker Compose编排后端、前端、数据库三个容器,单机部署,简单可靠。等哪天真的需要横向扩展了,再上K8s也不迟。
2. 后端工程搭建:目录结构、数据库版本管理、统一响应体设计
从真正敲第一行代码到后端工程跑起来,我花了大概两天时间。一天的产出是骨架和规范,另一天的产出是核心的通用能力。后面所有业务开发都是在这个骨架上长出来的。
2.1 工程结构与分层:单模块还是多模块?
一开始我也纠结过Maven多模块——bootstrap、system、business、common分开。后来权衡了一下,多模块的依赖管理成本对五个人团队来说太高了,每次改common都要重新install一次。最终选择了单Maven模块,按业务包名分包。
结构大概这样:
com.company.tms ├── TmsApplication.java ├── common // 通用能力:统一响应、异常、工具类、常量 ├── config // 配置类:MyBatis、Flyway、安全等 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体 ├── dto // 入参出参对象 └── enums // 枚举按技术分层而不是按业务域分包,初期开发会更快——每个人很清楚自己要改的文件在哪个包里,controller写接口、service写逻辑、mapper查数据,不用思考。但缺点是业务域之间不隔离,所以我们在service包内再按模块加子包,比如service.user、service.order、service.report,这样既保速度又留边界。
2.2 数据库版本管理:没有它就等于裸奔
这里是我最坚持的一点:任何新建的表结构变更,必须通过Flyway迁移脚本,不允许手工在数据库里改,也不允许用JPA的ddl-auto自动建表。
用Spring Boot + MyBatis-Plus时,很多人都喜欢直接在application.yml里配ddl-auto: update,开发期图省事。但这个习惯一旦养成,到了测试环境、生产环境,你根本不知道数据库到底长什么样。表改过几次、字段有没有加索引、数据有没有被清过,全是黑盒。
Flyway的用法很简单,在src/main/resources/db/migration下按版本号放SQL文件:
V1__init_schema.sql V2__add_user_table.sql V3__create_order_table.sql启动时Flyway会自动按版本号执行未跑过的脚本,并且记录在flyway_schema_history表里。团队任何一个人拉最新的代码,启动起来就是最新的表结构,不需要手动执行任何SQL。我们在V1脚本里建了最基础的用户表、角色表、权限表,每个字段都带注释,命名统一用下划线风格。
2.3 统一响应体与全局异常处理
前后端分离项目里,接口约定是第一步,如果你连返回结构都不统一,前端这边的封装就无从下手。从第一个接口开始,我们就强制统一返回结构。
{ "code": 0, "message": "success", "data": {} }对应的Java类是ResponseResult<T>,三个字段:业务码、提示信息、业务数据。code=0表示成功,非0表示失败,错误码表单独放在ErrorCode枚举里,每个错误码都有明确的语义。这里有个小经验:错误码不要直接复用HTTP状态码。HTTP的404表示路由不存在,而业务上可能是“订单不存在”,两者混在一起会让前端判断逻辑非常混乱。
全局异常处理是配合统一响应体用的。正常逻辑直接返回ResponseResult.success(data),异常则抛给全局的@RestControllerAdvice去兜底。自定义了BizException业务异常,凡是业务判断失败的地方直接throw new BizException(ErrorCode.ORDER_NOT_FOUND),不用在每个接口里写try-catch。这个设计看着简单,但真正让代码整洁度提高了一个档次。
2.4 实测:第一个接口从DAO层到Controller层的完整链路
第一个接口选了最不涉及业务的健康检查,就一个/api/health,返回当前服务状态。但它走完了完整的链路:Controller接收请求、Service层处理逻辑、Mapper查库、统一响应返回。
之后紧接着写了第一个有业务意义的接口:用户注册。
@PostMapping("/api/user/register") public ResponseResult<Long> register(@RequestBody @Valid RegisterDTO dto) { Long userId = userService.register(dto); return ResponseResult.success(userId); }Service层做的事情:校验用户名是否重复、密码加密、插入用户表。密码加密用BCrypt,这是Spring Security自带的加密器,能自动加盐,不需要自己处理盐值存储。这个接口虽小,但确立了后续开发的范式:DTO做参数校验、Service做业务、Controller只做路由。
3. 前端工程搭建:初始化脚手架、请求封装、路由与状态管理
后端框架稳定后,我花了一天时间把前端工程搭起来。前端的搭建不仅是能把页面跑起来,更要让整个请求链路、鉴权逻辑、路由控制从一开始就规范化。
3.1 Vite初始化:比Webpack爽在哪
创建项目用Vite官方脚手架:
npm create vite@latest tms-web -- --template vue-ts装完基础依赖后,我又加了vue-router、pinia、axios、element-plus。这里有个版本兼容性问题:npm create vite默认会装最新的Vite,如果Node版本过低可能会报错,建议Node直接上18+,省掉一堆版本兼容问题。
Vite开发体验和Webpack的差距,最直观的就是冷启动和热更新。冷启动一个包含几十个页面路由的项目,Webpack可能要等十几秒,Vite基本保持在一秒以内。原理是Vite利用了浏览器原生ESM支持,启动时不用打包全部文件,而是按需加载。对于开发阶段频繁改代码的体验提升太明显了。
3.2 目录结构:一开始就为长期维护留好位置
前端目录我花了心思设计,避免代码越写越乱:
src ├── api // 按模块拆分的接口定义 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── stores // Pinia状态 ├── types // TypeScript类型定义 ├── utils // 工具函数 └── views // 页面组件api目录下按后端模块划分:user.ts、order.ts、report.ts。每个接口函数都返回带泛型的类型,比如Promise<ResponseResult<UserInfo>>,这样在组件里调用接口时,回调函数的参数天然有类型提示,根本不用去翻接口文档。
3.3 axios封装:拦截器才是核心
axios的封装是前端工程质量的关键。我们的封装有两个重点:请求拦截器和响应拦截器。
// utils/request.ts const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:附带认证信息 service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理业务错误码 service.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message) if (res.code === 401) { router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这里踩过一个坑:响应拦截器里如果直接return res.data,调用方拿到的直接是data里的内容,不再需要response.data.data这种两重取值的写法。前端同事一开始老是写错,因为这个response类型是axios的完整响应,而我们返回的是res.data,两个data搞混了是家常便饭。所以我在TypeScript类型声明里把自定义返回结构写清楚了,类型系统帮忙兜底。
另外401的判断非常关键。token过期时,后端统一返回code=401,这时前端应该做两件事:清理本地token、跳转登录页。如果不做这层拦截,每个业务页面里都要单独处理登录失效,那可就太痛苦了。
3.4 路由守卫与页面骨架
路由配置对应后端的功能模块:登录页、首页、用户管理、订单管理、报表中心。用meta字段标记每个路由是否需要登录、需要的权限码。
{ path: '/user', name: 'UserManage', component: () => import('@/views/user/index.vue'), meta: { requiresAuth: true, permission: 'user:list' } }路由守卫里做两件事:未登录跳登录页;已登录但权限不足则提示无权限。整个鉴权逻辑在前端侧是透明拦截的,业务页面里不需要再判断用户角色。
第一个能点击的完整页面是登录页。登录接口调用后端/api/auth/login,成功后把token存到localStorage,同时把用户信息存到Pinia。这套流程跑通后,前后端算是真正“接上头”了。
4. 前后端联调:接口文档规范、跨域处理与首个完整流程
前后端各自的任务完成了一部分后,联调是整个项目最考验耐心的环节。我们用了好几个手段来减少联调期的摩擦,但依然踩了几个有代表性的坑。
4.1 接口文档先行,但不求一步到位
联调的基础是接口文档。我们用的是Apifox,既可以设计接口文档,又能生成Mock数据,还能直接调试真实接口。好处是接口文档和调试在同一套环境下,改动即时生效,比Swagger UI在团队协作上体验好很多。
文档定义的关键字段包括:请求路径、请求方法、请求头、Query参数、Body的JSON结构、返回结构、以及每个字段的类型和说明。开发中重点是由后端先行定义,因为后端写接口时对数据边界最清楚。具体流程是:后端计划开发某个模块时,先在Apifox建好接口文档,然后把链接发到群里,前端同事开始Mock联调;后端实现完后,前端切到真实环境调试。
在文档时效上我们也踩了坑。有个接口改了返回字段,后端忘了同步文档,导致前端同事按旧文档开发,联调时发现字段对不上,排查了大半天。后来定了一条铁律:谁改了接口,谁负责当天下班前更新文档,不改文档的接口不让上线。
4.2 跨域问题:本地开发用Vite代理解决
跨域是前后端分离绕不开的话题。我们本地开发环境,前端跑在5173端口,后端跑在8080端口,浏览器的同源策略会拦截不同端口的请求。解决方式有两种:后端配CORS,或者前端配代理。
我的建议是:本地开发优先用Vite代理,测试环境优先用Nginx反代。CORS这东西,虽然配置简单,但一旦涉及带Cookie的请求(withCredentials: true),前后端都要配合改,坑比较多。用代理则完全不需要后端知道前端的存在。
Vite代理配置:
// vite.config.ts server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这个配置的意思是:前端请求/api/xxx时,Vite会把请求转发到http://localhost:8080/api/xxx,浏览器看到的请求是同源的,不存在跨域问题。
4.3 从注册登录到用户列表:首个完整业务闭环
联调的第一个完整流程,我们选了“注册 → 登录 → 获取用户列表 → 展示”这条链路。原因很简单:这条链路涉及认证token的生成和校验,是几乎所有接口的前置依赖,把这条链路跑通了,后面所有业务模块的联调都只是重复劳动。
联调中发现了一个有意思的坑:响应拦截器里已经写了401跳登录的逻辑,但当用户手动点击“退出登录”时,会主动调用后端接口让token失效,而后端返回的也是401。这时候前端拦截器会再次跳转到登录页,页面会闪一下。后来把退出接口的错误码单独定义为2000,不触发401的跳转逻辑。这个细节虽然小,但很能体现前后端契约设计时的互相理解。
5. 团队分工与协作:五个人怎么把活拆得明明白白
技术栈和工程骨架都定了,接下来最核心的事就是“分活”。五个人,两个前端、两个后端、我(全栈兼任技术负责人),怎么分工才能不打架、不堵塞、不返工?
5.1 按业务模块拆任务,而不是按技术层拆
最常见的错误分工方式就是“一个前端负责页面、一个后端负责接口”——这种按技术层拆的方式,会导致每个人对业务的理解都不完整,沟通成本成倍增加。我们选择的是全栈对应关系按模块拆:
| 成员 | 角色 | 负责模块 |
|---|---|---|
| 我 | 全栈/技术负责人 | 系统脚手架、登录鉴权、接口规范、联调协调 |
| 后端A | 后端开发 | 用户管理、角色权限、组织架构 |
| 后端B | 后端开发 | 订单管理、报表统计、数据导入导出 |
| 前端A | 前端开发 | 登录页、用户管理、角色权限页面 |
| 前端B | 前端开发 | 订单管理、报表统计页面 |
这样拆的好处是:每个业务模块都有一个明确的后端人对一个明确的前端人。后端A和前段A在业务上是一对一的,沟通时可以直接说“用户管理那个列表的分页参数改了”,不需要经过中间人传达。
但这里有个前提——模块之间要尽量解耦。比如权限模块和订单模块之间确实存在关系(订单要关联用户),但我们通过接口约定而不是数据库直接关联来解决,这样两边并行开发时不需要等对方。
5.2 Git分支策略:简洁务实的GitFlow轻量版
我见过很多团队一上来就搞复杂的GitFlow,develop、release、hotfix、feature全上,结果小团队根本维护不过来。我们的策略是主干开发加特性分支:
main分支:保持可发布状态,任何时刻都能上线dev分支:日常开发集成分支feature/xxx分支:每个功能从dev切出,完成后合并回dev
开发流程是:每天早上从dev切出最新的feature分支,写完自测后提交Merge Request(简称MR),指定模块对应的搭档Review。Code Review通过后合回dev。发测试环境前,dev合并到main打tag。
Git提交信息用的是Conventional Commits规范,格式是type(scope): description,例如feat(user): add user list page、fix(order): fix export encoding issue。这个规范的价值不在于格式本身,而在于git log能直接当Changelog用,看着提交记录就能快速定位某次改动的意图。
5.3 任务拆解与工时估算:用看板而不是项目管理系统
我们用了极简的看板:三列——待办、进行中、已完成。每张卡片只描述一个独立可交付的小任务,比如“完成后端订单搜索接口(含分页)”而不是“做订单模块”。
我对任务拆解的最小粒度的经验是:一张卡片的工作量最好控制在0.5天到2天之间。超过2天的任务要继续拆,否则每天站会时根本说不清“卡在哪了”。不到半天的任务,比如“修一个按钮样式”,没必要单独建卡,挂在相关卡片下面就行。
这里必须说一个反直觉的事:任务拆得越细,进度反而越快。因为每完成一个小卡片,团队都会有实实在在的成就感,而且很容易发现风险——如果一张卡到了第三天还没关闭,那就说明任务估算有误或者遇到技术卡点,需要赶紧介入。
5.4 站会与节奏控制
每天上午10点固定站会,15分钟以内。每个人只用说三个问题:昨天做了什么、今天打算做什么、有没有需要别人配合的阻塞项。我们不允许在站会上讨论解决方案,超时的技术问题会后单独拉人聊,这样能保证站会的高效。
每周五下午做一次阶段复盘,不是形式主义,而是真正看本周完成率和下周计划。复盘时我会把看板数据拉出来,看看每个人实际完成的故事点和预估差了多远,用于校准后面的节奏。前两周的估时偏差在30%以上,第三周开始就稳定在10%以内了——这个数据校正能力非常重要。
6. 踩坑实录:这些坑不踩一遍真的不长记性
两个多月的开发周期里,我记录了十几个能写进“从0到1”经验帖的坑。挑几个最有代表性的分享出来,这些都是常规文档里不会写的内容。
6.1 “在我机器上能跑”的真相:环境统一太重要了
项目启动第四天,后端A说他本地跑不起来,报一个奇怪的依赖异常。最后发现是他的JDK依然是11,而项目用的Spring Boot 3.x强制要求JDK 17。他拉代码的时候根本没留意README里的环境要求。
这个问题的本质是团队没有统一的开发环境校验。后来我们做了一件事:在工程根目录放一个.java-version文件,再加上README里明确写“安装JDK 17和Node 18+,否则项目无法启动”,并且把这段放在README最显眼的位置。前端那边也有类似问题——Node 16跑Vite 5会直接报错。这之后,“在我机器上能跑”的声音基本消失了。
6.2 数据库字段命名不一致引发的联调事故
有次前端A在联调用户列表的筛选功能时,反馈接口返回的字段里没有createTime,只有created_at。排查后发现:数据库表字段是下划线风格created_at,后端实体里MyBatis-Plus默认开启了驼峰映射,自动转成了createdAt,但后端的VO(视图对象)里字段名写的是createTime,字段映射在最后一步断了。
这个坑的根子是命名规范没有在项目启动时统一。我们后来在工程规范里明确规定:数据库字段一律下划线,代码统一驼峰,MyBatis-Plus开启驼峰映射,DTO/VO字段名必须和响应体完全一致。同时要求后端在合代码前自己先调用一次真实接口,用浏览器看返回结构,确认无误再接给前端。
6.3 第一个完整流程联调时,Mock数据和真实数据的差异
Apifox的Mock功能确实香,文档建好后前端马上就能用Mock数据开发页面。但Mock数据有个大坑:Mock生成的数字ID是非常规整的大整数,而后端真实数据是雪花算法生成的Long类型。前端存ID时如果用JavaScript的Number类型,当ID超过JavaScript的Number.MAX_SAFE_INTEGER(9007199254740991)时会出现精度丢失。雪花ID显然已经超过了这个范围。
第一次联调时前端A就发现了这个问题:点击编辑用户,页面显示的ID和列表里的不一致,最后一位变成了0。解决方案是后端在JSON序列化时把Long类型的ID转成String传给前端:
@JsonSerialize(using = ToStringSerializer.class) private Long id;这是个前端“看着是String、传回后端能自动转Long”的处理方式。这个坑在纯前端团队或者纯后端团队各自开发时很难被发现,只有在联调时才会暴露,所以特别值得提一下。
6.4 阶段复盘:做对了什么,做错了什么
项目第一个月结束时,我做了次相对正式的复盘。做得好的是:
- 技术选型足够克制,没有引入团队不熟悉的组件和框架
- 脚手架搭得比较细,后面业务开发很顺
- 接口文档早定和统一响应体落地及时,联调效率高于预期
做得不好的地方:
- 有些任务的工时估算过于乐观,特别是前端写复杂表格页面的时间预估偏差很大
- 环境问题在初期浪费了不少时间,下一次项目启动时,我会把“环境准备文档”作为第一步先写出来
- UI设计稿出来得比页面开发晚,导致前端同学有些页面返工了两次
写在最后
带一个从0到1的项目,最累的往往不是写代码,而是把所有参与者的节奏调成同一个频率。技术选型、工程搭建、接口规范、分工协作,所有这些“打地基”的工作,短期看好像耽误了写业务代码的时间,但长期看,它们才是决定项目能不能按时交付的关键因素。
这个系列的下一篇,我会记录用户权限模块从需求到落地的完整过程,包括RBAC模型怎么设计、动态菜单怎么实现、以及权限这块前后端怎么配合才不会互相踢皮球。项目还在继续,坑也在继续踩,希望这些记录能让正在走同样路的人少走一些弯路。