做第一个全栈项目的过程,就像第一次独立装修一套房子,每一道工序都要自己上手,每一步都可能踩到意想不到的坑。我选的题目是任务管理器,一个看似简单、实际涵盖了用户体系、增删改查、状态流转、多端适配的经典业务系统。用Vue 3做Web端,Golang的Gin框架写后端接口,数据层交给MySQL,顺便用uniapp规划了移动端的扩展方向——这算是目前中小型项目里非常标准的一套全栈组合了。
这篇复盘既是对我自己开发过程的记录,也是给同样准备独立做全栈项目的朋友一份参考。从需求拆解、技术选型、数据库设计、接口约定,到前端页面落地、联调部署、问题排查,我会把整个过程里值得说的地方都写出来,特别是那些只有亲自踩过坑才能总结出来的细节。无论你是想用这个项目练手,还是已经在规划自己的第一个全栈作品,这篇内容应该都能帮你在动工之前把思路理得更清楚。
1. 项目概览:这个任务管理器到底做了什么
1.1 为什么第一个全栈项目选任务管理器
技术圈里有种普遍认知:想快速成长,就去做一个“麻雀虽小、五脏俱全”的业务系统。任务管理器恰好是这类系统的典型代表。它不像电商那么庞大,不需要处理支付、库存、物流这些复杂域,但又保留了真正的全栈项目必须具备的核心模块——用户注册登录、数据建模、增删改查、状态流转、列表筛选、分页排序。这些功能做一遍,整个前后端开发的主干流程就算完整走通了。
还有一个很现实的原因:任务管理器的需求边界非常清晰。你可以把一个功能完善的任务管理器做出来,也可以只做核心的待办事项。它的复杂度完全由你自己掌控,不会出现需求发散、越做越没边的情况。我给自己定的版本范围很明确:支持用户注册登录、任务的新增删除修改查询、按状态和优先级筛选、按截止时间和创建时间排序、基础的数据统计。这个范围做下来,刚好能把一个全栈工程师需要具备的基本能力全部覆盖到。
1.2 技术栈选型:为什么是Vue 3 + Golang + uniapp
技术栈的选型我做了一些实际的对比。前端框架首选Vue 3,原因很直接:Composition API对逻辑复用特别友好,一个任务的增删改查逻辑可以封装成独立的组合式函数,组件之间共享状态非常干净。加上TypeScript的类型提示,后端的接口数据结构能直接映射到前端类型定义,联调的时候省了大量对照文档的时间。组件库用的Element Plus,表格、表单、日期选择器这些都是现成的,开发速度很快。
后端选择Golang主要是看中三点:第一,编译型语言部署极简单,一个二进制文件扔到服务器上就能跑,不像Node.js项目要处理一堆依赖安装;第二,性能足够好,虽然任务管理器的并发量不大,但学习过程中用到的goroutine、channel这些并发模型,对后续进阶帮助很大;第三,Gin框架的中间件机制清晰,JWT认证、日志记录、错误处理这些横切关注点可以非常优雅地组织起来。数据库用MySQL,这是最通用的关系型数据库选择,GORM作为ORM工具能大幅减少手写SQL的工作量。
提到uniapp,我承认这是有一点点“私心”的规划。任务管理器这种工具类应用,很多人希望在手机上顺手记一笔。如果用纯H5做移动端适配,在iPhone的刘海屏和Android的各种异形屏上调试会让人崩溃。uniapp至少保留了后续打包成App的可能性。不过在第一个版本里,我没有完整实现移动端,而是把核心逻辑全部收敛在前后端API层,Web端用响应式布局适配移动设备。这个取舍后面会细说。
1.3 功能清单与版本规划
动工之前我写了一页纸的产品需求清单,标注了“必须做”和“以后做”两个维度。这样做的好处是开发过程中不会被临时冒出来的想法带偏。
第一个版本必须包含的功能项:
- 用户注册、登录、退出,登录后返回JWT令牌
- 任务的创建、查询、修改、删除
- 任务字段:标题、描述、状态、优先级、标签、截止时间
- 任务列表支持按状态、优先级筛选,支持模糊搜索、分页、排序
- 任务状态流转:待办 → 进行中 → 已完成
- 个人任务统计数据:总数、已完成数、进行中数、过期未完成数
暂缓的功能包括:任务的子任务拆分、提醒通知、日历视图、多人协作、数据导出。这些功能不是不重要,而是在第一个版本引入会破坏核心链路的完整性。比如多人协作,一旦做了就要考虑共享、权限、冲突处理,这会直接把开发周期拉长一倍以上。全栈项目的第一版,最重要的原则是完成闭环,而不是功能齐全。
2. 架构设计与数据模型
2.1 前后端分离架构与目录结构
我采用的是经典的前后端分离架构:Vue 3前端只负责界面渲染和用户交互,Golang后端只提供JSON格式的API接口,两者通过HTTP通信。前端运行在开发服务器上,通过Vite的代理把/api前缀的请求转发到后端的端口;生产环境则由Nginx统一托管前端静态文件和反向代理后端接口。
后端项目的目录结构是参考了Gin项目的社区最佳实践,按职责分模块:
task-manager-server/ ├── main.go // 入口文件:加载配置、初始化数据库、注册路由 ├── config/ │ ├── config.go // 配置结构体定义 │ └── config.yaml // 环境配置 ├── models/ │ ├── user.go // 用户模型 │ └── task.go // 任务模型 ├── middlewares/ │ ├── jwt_auth.go // JWT认证中间件 │ ├── cors.go // 跨域处理 │ └── logger.go // 请求日志 ├── controllers/ │ ├── auth_controller.go // 认证相关接口 │ └── task_controller.go // 任务相关接口 ├── services/ │ ├── auth_service.go // 认证业务逻辑 │ └── task_service.go // 任务业务逻辑 ├── dto/ │ ├── auth_dto.go // 请求/响应数据结构 │ └── task_dto.go ├── routes/ │ └── router.go // 路由注册 ├── utils/ │ ├── jwt.go // JWT工具函数 │ └── response.go // 统一响应封装 └── database/ └── db.go // 数据库初始化连接这种分层的好处每个关注点各归其位:控制器只做参数接收和数据返回,业务逻辑全部下沉到service层,数据模型在models层定义。对新手来说最大的收益是代码结构一目了然,出问题的时候能快速定位——接口报错先看controller,逻辑不对查service,字段对不上看models。
前端的目录结构相对简单一些,按页面(views)、组件(components)、状态(store)、路由(router)、API封装(api)五个维度组织。API层单独抽出来的好处是所有的后端请求地址都集中在一个文件里,后端接口路径变了只需要改一处,不用满项目搜索。
2.2 数据库表设计与状态流转
数据库总共就两张表,设计得很克制。用户表只保留了核心字段,任务表除了基本字段外,在建索引上做了思考。直接看建表语句:
CREATE TABLE users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE tasks ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待办 1进行中 2已完成', priority TINYINT NOT NULL DEFAULT 1 COMMENT '1低 2中 3高', tag VARCHAR(50), due_date DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_status (user_id, status), KEY idx_user_due_date (user_id, due_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段用TINYINT表示状态和优先级,而不是直接用字符串,很多人不太理解这点。从数据库角度看,整数型的查询效率更高,存储空间更小;从代码角度看,状态定义成常量后,前端只需要映射数字对应的文案和标签颜色就行。比如status === 2就是已完成,配合TypeScript的联合类型,写起来相当舒服。
状态流转我实现了一个非常轻量的状态机。后端在更新状态时检查合法迁移路径:待办只能流转到进行中,进行中只能流转到已完成。看起来只是一个简单的switch判断,但这个问题反映了一个设计思维——不是所有操作都应该被允许,业务规则要放在后端用代码保护,不能只靠前端控制。
字符集用utf8mb4是因为任务标题和标签可能出现表情符号,比如客户想给某个任务加个 📌 标记。utf8mb4是完整的UTF-8编码,能存emoji,而且和旧版utf8在大部分场景下完全兼容。
2.3 接口约定与统一响应格式
前后端联调最怕的就是接口各写各的,所以在开发后端之前,我把接口规范先定死了。所有接口统一使用/api/v1前缀,版本号直接放在路径里,后续如果有不兼容的改动,可以平滑升级到v2而不用影响已上线的客户端。
响应格式统一封装成一个JSON结构:
{ "code": 0, "message": "ok", "data": { } }code为0表示成功,非0表示业务错误。业务错误码我稍微做了分类,前缀不同方便排查:100开头是认证相关错误,比如token过期、无效token;200开头是参数校验错误;300开头是资源操作错误,比如任务不存在、无权限修改。这种错误码分类的方式比直接用HTTP状态码传达更多信息,前端拿到code之后可以精确地给出对应的用户提示。
分页接口统一接收page和page_size参数,返回格式包含总数和列表数据:
{ "code": 0, "message": "ok", "data": { "list": [], "total": 50, "page": 1, "page_size": 10 } }总数字段必须由后端返回,不能前端自己算,原因是当数据量变大之后,前端一次性拉取所有数据做本地分页的方案会快速失效,后端分页是唯一可行的路径。因此从第一版开始就养成这个好习惯,后续数据量上来也不会踩坑。
3. 后端实现:从零搭建Gin服务
3.1 项目初始化与依赖管理
后端项目我用go mod管理依赖。模块名取的是task-manager-server,后续所有内部包都基于这个路径来引用。初始化命令和依赖安装如下:
go mod init task-manager-server go get -u github.com/gin-gonic/gin go get -u gorm.io/gorm go get -u gorm.io/driver/mysql go get -u github.com/golang-jwt/jwt/v5 go get -u golang.org/x/crypto/bcrypt go get -u github.com/gin-contrib/cors配置管理我选择了最朴素的方式——一个config.yaml文件加一个结构体。数据库地址、端口、JWT密钥、token过期时间都放在这里。配置从环境变量读取的方式更适合生产环境,但个人项目用yaml文件已经足够了,关键是把JWT密钥单独留成环境变量注入,不要把生产环境的密钥提交到代码仓库。
连数据库的代码要注意GORM的时区设置。直接在DSN参数里加上loc=Local和parseTime=True,前者保证时间按本地时区存取,后者让MySQL的DATETIME类型能自动映射到Go的time.Time类型。不加这两个参数,查询出来的时间字段会出现8小时的时差,排查起来非常头疼。
3.2 JWT认证与用户模块
用户模块的核心是注册和登录。注册时密码不能明文存储,我用的是bcrypt算法做哈希。相比MD5和SHA家族的快速哈希算法,bcrypt是专门为密码存储设计的慢哈希,自带盐值,同样的密码每次生成的哈希值都不同,暴力破解成本高得多。虽然bcrypt计算稍慢,但认证场景下这个开销完全可以接受。
登录成功后签发JWT,载荷只放三个核心字段:用户ID、用户名、过期时间。JWT的内部信息不适合放太多东西,因为token的体积会影响请求头的大小,同时也会增加token泄露后的风险面。关键信息需要查询时再从数据库读取,而不是一股脑塞进token里。
claims := jwt.MapClaims{ "user_id": user.ID, "username": user.Username, "exp": time.Now().Add(24 * time.Hour).Unix(), }认证中间件的逻辑值得一提。JWT的验证不是在每个接口里重复写的,而是通过Gin中间件统一拦截。在routes注册时,需要登录的接口都加在这个中间件之后。中间件解析token成功后,把用户信息塞进请求上下文,后续业务代码直接c.Get("user_id")就能拿到当前用户ID,不用再去查询数据库。
3.3 任务模块核心接口实现
任务模块的接口是标准REST风格:GET列表、POST创建、PUT更新、DELETE删除、PATCH改状态。每个接口第一件事是确认资源归属权——从JWT里拿到的userID和待操作任务的user_id比对,不一致直接返回无权访问。任务管理器是私有的个人工具,不存在共享场景,所以这个校验非常简单。
创建任务的逻辑包含参数校验和默认值处理。标题必填、长度控制在200字以内;状态默认待办、优先级默认中;截止时间可以留空。校验逻辑放在service层而不是controller层,是为了保证复用性。如果后面做批量导入功能,同样的检查逻辑可以直接调用service层方法。
列表查询是接口里最有讲究的一个。它需要组合筛选、搜索、分页、排序多个维度。GORM的链式调用写起来很清晰:
query := db.Where("user_id = ?", userID) if req.Status != nil { query = query.Where("status = ?", *req.Status) } if req.Priority != nil { query = query.Where("priority = ?", *req.Priority) } if req.Keyword != "" { query = query.Where("title LIKE ?", "%"+req.Keyword+"%") }排序逻辑做了白名单控制。前端传入的sort_by字段只会被映射到created_at、due_date、priority三个固定字段,这样可以避免数据库列名被外部任意指定。有些项目会在排序字段上直接拼接SQL造成注入漏洞,白名单映射是最稳妥的做法。
3.4 中间件:CORS、日志与错误恢复
CORS中间件是前后端分离项目必须处理的环节。开发环境前端跑在5173端口,后端跑在8080端口,跨域请求如果不在后端允许跨域,浏览器会直接拦截。我用的gin-contrib/cors配置了允许的来源列表、允许的方法和请求头。生产环境如果由Nginx反向代理,前后端变成同源,这个中间件就不起作用了,但保留着没有坏处,以后前后端域名分离也不用再改后端。
日志中间件也是必需的。每个请求的IP、路径、耗时、状态码会输出到终端和文件。开发时定位问题主要靠它——某个接口请求超时了,看日志就知道耗时多少;某个接口返回500了,错误栈在哪里也能从日志里翻到。
Gin框架自带的Recovery中间件可以拦截运行时panic,防止整个服务因一个协程的崩溃而挂掉。我的处理方式是在Recovery之外再加一层自己的错误处理逻辑:业务错误通过c.AbortWithStatusJSON直接返回统一的错误响应,同时用logger记录错误的详细信息方便调试。
4. 前端实现:Vue3 + Pinia + Element Plus
4.1 前端工程化与目录规划
前端我用Vite搭建工程,它的冷启动速度和热更新体验明显比Webpack好,新项目直接用它不会有任何纠结。脚手架创建完之后的目录规划很关键,我按下面的结构整理:
task-manager-web/ ├── src/ │ ├── api/ │ │ ├── auth.ts │ │ └── task.ts │ ├── assets/ │ ├── components/ │ │ ├── TaskFormDialog.vue │ │ └── TaskItem.vue │ ├── router/ │ │ └── index.ts │ ├── stores/ │ │ ├── auth.ts │ │ └── task.ts │ ├── views/ │ │ ├── LoginView.vue │ │ └── TaskManagerView.vue │ ├── types/ │ │ └── index.ts │ ├── utils/ │ │ └── request.ts │ └── App.vueTS类型定义是整个前端开发里我最看重的一部分。后端每个接口的请求参数和响应结构,在前端都定义成对应的interface,Axios二次封装的Request函数是泛型化的,接口调用时天然具备完整的类型提示。找人协作开发时,只看类型定义就能知道接口的输入输出长什么样,根本不用打开后端代码。
4.2 登录与状态持久化
登录页做得简洁,核心逻辑在auth store里。用户输入用户名密码,前端调登录接口,成功后把token存入内存和localStorage,同时调用获取用户信息接口把用户信息写入store。之后每次发请求,Axios的请求拦截器都会从store取token并添加到Authorization头。
响应拦截器里做了统一的错误处理。业务错误返回的code不为0时,根据错误码给出不同的提示信息,比如10001就是登录过期,直接弹出提示并跳转到登录页。网络错误单独处理,提示用户检查网络连接。这套机制写完后,每个具体页面的接口调用代码都非常干净,不用重复处理错误分支。
路由守卫控制页面访问权限。任务管理页面要求必须有token才能进,没有token访问时自动重定向到登录页。这个跳转逻辑容易在细节上出问题——访问被守卫拦截跳转到登录页时,next函数要用next("/login")而不是直接next(false),并且要把用户原本想去的完整地址记录下来,登录成功后路由到目标页面而不是固定首页。
4.3 任务列表:筛选、排序与分页
任务列表是全项目前端工作量最大的模块。顶部是一排筛选条件,包括状态、优先级、搜索关键词;中间是表格区域,展示标题、状态标签、优先级标签、截止时间、创建时间,右侧是编辑和删除按钮;底部是分页组件。筛选条件变化时重新拉列表数据,这个交互看起来很自然,实现上有几个细节需要考虑。
筛选状态我用URL参数和store状态双重管理。用vue-router的query参数同步筛选条件,好处是刷新页面、分享链接时筛选状态不会丢;同时把当前筛选条件放进store,切换页面后回来还能保持住。两者同步的逻辑写起来稍微绕一点,但在任务管理这种频繁切换场景里,体验提升相当明显。
状态和优先级在表格里用el-tag组件展示,根据数值映射不同的文案和颜色。我把映射逻辑抽成一个公共函数,比如状态0显示灰色“待办”标签、状态1显示蓝色“进行中”、状态2显示绿色“已完成”;优先级1、2、3分别对应“低”、“中”、“高”,颜色从灰色渐变到红色。这样数值和展示完全解耦,后端传什么值前端只做翻译,遇到异常值还能从视觉效果上一眼发现。
4.4 移动端适配与uniapp复用策略
一开始我也试图用uniapp写一套移动端代码,把Web端的功能完整复制一份。做到一半我发现问题很大:两套代码维护成本翻倍、移动端交互和Web端差异大、细节打磨时间不够。冷静下来之后,我调整了策略——第一个版本用响应式布局让Web端适配移动浏览器,uniapp的完整App版本放到后置计划。
响应式方案上,Element Plus的表单组件自带栅格系统,表单在窄屏时自动变成单列;表格在移动端体验很差,所以我额外加了一个移动端友好的卡片视图。通过CSS媒体查询判断屏幕宽度,窄屏时表格隐藏、卡片显示,每条任务以卡片形式展示在屏幕上,操作按钮改成图标加文字。大部分工具类应用在手机浏览器里的可用性已经很高了,如果后续用户反馈强烈,再开发真正的移动端App。
真正从系统层面做跨端规划的核心是API的复用。这套架构的价值在于,无论是uniapp还是小程序,后续只需要重新写一层UI和API调用,后端完全不需要改动。因为所有的业务逻辑都封装在服务端,客户端的职责被压缩到最小。
5. 部署、常见问题与优化方向
5.1 本地联调与生产部署方案
本地开发时,前端通过Vite的代理配置解决跨域问题,生产环境的前后端统一由Nginx承载。Nginx配置里有几个关键点值得记录:
server { listen 80; server_name your_domain.com; # 前端静态文件 root /var/www/task-manager; index index.html; # 前端路由history模式,刷新404问题靠这个解决 location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }后端编译后直接扔到服务器上运行,注意Gin默认是debug模式会打印大量调试日志。生产环境用环境变量切换到release模式:GIN_MODE=release ./task-manager-server。数据库连接、JWT密钥这些配置通过config.yaml读取,JWT密钥单独留给环境变量注入。
5.2 常见问题速查表
搭建和联调过程中踩过的坑,我把它整理成了速查表,遇到同类问题的朋友可以直接对照排查:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 前端请求接口报CORS错误 | 后端未配置跨域白名单 | 在CORS中间件中加入前端请求来源域名 |
| 时间字段差了8个小时 | 数据库连接DSN缺少时区参数 | DSN追加loc=Local&parseTime=True |
| 刷新页面404 | Vite开发环境路由未配置history回退 | 服务端配置try_files回退到index.html |
| 修改任务变成了新增 | 更新时缺少ID传递或误用create方法 | 更新操作显式调用db.Model(&task).Updates() |
| token过期后接口返回不统一 | 中间件未捕获JWT的校验错误 | 中间件统一处理token解析的各类错误返回 |
| 登录状态刷新丢失 | token未持久化或store恢复逻辑缺失 | 应用启动时从localStorage读取token并校验有效 |
数据库时间时区这个坑我印象最深。有一次测试新增任务,前端显示的截止时间明明是晚上8点,保存后查数据库变成了凌晨0点,差了整整8个小时。一开始以为是前端传参问题,后来逐层排查才发现是数据库连接的DSN没有指定loc=Local,Go的database/sql默认把DATETIME按UTC解析了。改掉之后时间就正常了。
5.3 项目复盘与后续扩展计划
项目做完之后我重新审视了一遍代码,觉得有两个地方当时可以做得更好。一是状态流转虽然加了后端检查,但检查逻辑只是简单的条件判断,没有抽成独立的状态机模块。如果后续要增加“已取消”、“已推迟”之类的状态,现在的结构会越改越复杂。二是前端对删除操作没有做过软删除——用户误删任务后,数据直接物理不存在了,没有任何后悔的机会。从用户体验上讲,加一个回收站机制会更友好。
后续我给自己列了一份清晰的扩展清单,按优先级排列:第一是任务标签的多选支持,现在只是一个字符串字段,改成关联表后才方便按标签集中筛选;第二是数据看板,用图表展示一周内任务的完成情况和逾期情况;第三是日历视图,把截止日期映射到月历上,这是任务管理工具使用频率最高的功能之一;第四是微信小程序版本,API已经准备好了,前端复用现有逻辑改造即可。
做完这个项目最大的感受是,全栈开发真正的难点不在某个具体技术,而在于思维的频繁切换。写后端时,你考虑的是数据怎么存、规则怎么定、边界怎么处理;切到前端,你要考虑的是用户想要什么反馈、交互是否直觉、异常状态怎么呈现。这种频繁的身份切换会逼着你建立一套完整的闭环思维,而这恰恰是独立开发一个产品最宝贵的能力。如果有朋友看完这篇总结正准备动手,我的建议是先别着急写代码,花几天时间把需求、表结构、接口定义画清楚,这个前期时间花得越充分,后期返工的次数就越少。