ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Go+Vue3全栈实战:从零实现拉手网团购平台完整教程

Go+Vue3全栈实战:从零实现拉手网团购平台完整教程 不用把“拉手网项目实战”想得太玄乎把它当成一个典型的本地生活团购业务来练手就行。这个项目我前后搭过两遍第一遍是只做后端接口第二遍才补上完整的 Vue3 前端页面把用户、商家、团购商品、订单、优惠券、秒杀这几条主线全部走通。技术栈选的是 Go Gin GORM go-redis MySQL前端用 Vue3 Element Plus。整个项目做完大约是 20 个接口、12 张核心表、7 个前端页面工作量对一个新手来说刚刚好不会简单到学不到东西也不会复杂到让你中途放弃。这套组合是目前国内中小厂和外包项目里非常主流的一套敏捷栈。Gin 负责路由和中间件GORM 负责数据库操作go-redis 负责缓存和秒杀场景的库存预扣前端 Vue3 负责页面渲染和交互。你把它完整跑通之后至少能搞明白三件事前后端是怎么通过 HTTP 接口协作的、关系型数据表是怎么设计的、以及 Redis 在真实业务里到底用在哪里。这篇文章我会按照我当时从零到一的做法把项目拆成七个部分每一步都给你可以直接粘过去改的代码和 SQL尽量少讲空理论多给实操内容。1. 项目概述与业务建模1.1 拉手网的核心业务是什么拉手网当年是本地生活团购领域的典型产品核心玩法是 Groupon 模式平台邀请本地商家入驻商家拿出本地生活服务项目餐饮、美容、KTV、酒店做大幅度折扣用户在线购买团购券然后到店消费核销。它和传统电商最大的区别是用户买的不是实物商品而是一种“消费凭证”。把这个业务搬进项目实战里比随便做一个学生管理系统有价值得多。你会遇到一整套完整电商链路商品上下架、库存扣减、订单状态流转、优惠券叠加校验、支付结果处理、秒杀防超卖。这些场景恰恰是后端岗位面试时最常被追问的内容也是从“会写 CRUD”到“能设计业务系统”之间最重要的过渡。我重新梳理这个项目时把业务收敛成三个角色、五个核心对象三个角色C 端用户、B 端商家、平台运营方五个核心对象用户、商家门店、团购商品SKU、订单、优惠券这个模型足够支撑一个完整的团购平台雏形。商家登录后台发布团购套餐设置原价、团购价、库存和售卖时间用户在前台浏览商品列表搜索和筛选领取优惠券后下单购买平台记录每一笔订单的状态变化并对商品库存做实时扣减。1.2 功能模块拆解后端接口按业务模块拆成六大块用户模块注册、登录、个人信息查询、我的优惠券商家模块商家注册、商品发布、商品上下架、订单查看商品模块商品列表、商品详情、分类筛选、关键词搜索订单模块创建订单、订单详情、订单列表、取消订单优惠券模块发放优惠券、领取优惠券、下单时校验使用秒杀模块限时抢购、库存预扣、异步落单前端页面我分了七个页面。C 端部分有首页商品列表、商品详情页、订单确认页、订单列表页、登录注册页B 端部分有商家后台商品管理页和订单管理页。如果时间紧张商家后台可以先砍掉把 B 端接口都写完留着前端只做 C 端页面。1.3 整体架构与数据走向整个项目的架构是标准的前后端分离后端暴露 RESTful API前端通过 axios 发起 HTTP 请求消费接口。项目运行起来以后一次完整的用户下单请求数据走向是这样的用户在前端点击“立即购买”前端携带 token 调用 POST /api/v1/orders 创建订单后端先解析 token 确认用户身份然后读取请求里的商品 ID 和数量查询商品库存是否充足用数据库事务扣减库存并生成订单记录订单创建成功后前端跳转到订单列表页轮询订单状态模拟支付成功。整个链路里最需要注意的是订单和库存的数据一致性。我建议新手第一版就用 MySQL 事务加上乐观锁来处理扣库存不要一上来就上消息队列先把基础能力做对再说。等到秒杀场景那一章我们会把 Redis 预扣库存的方案接进来再讨论更高吞吐量的设计。2. 技术选型与开发环境准备2.1 技术栈选型分析先说一下为什么选 Go 而不是 Java 或 Node。拉手网这种业务本质上是 IO 密集型和数据一致性要求高的系统Go 在 Web 服务开发上有一个非常好的平衡点并发模型简单部署产物是单个二进制文件学习曲线比 Java 全家桶平缓得多。而 Gin 是目前 Go 里使用率最高的 Web 框架中间件生态成熟用它的体验非常顺滑。数据库层选 GORM 的原因很实在它对新手非常友好结构体定义好了自动迁移就能建表常见的关联查询、分页、事务都封装好了。你不需要写一行原生 SQL 就能完成 90% 的数据库操作这对快速跑通一个全栈项目特别重要。go-redis v9 是 Go 社区的事实标准无论做缓存还是秒杀库存预扣都非常顺手。前端我为什么坚持上 Vue3 而不是只做纯接口因为做全栈项目的一个核心目的是让你看到接口真正的消费方式。你可以从零学会 Vue 模板语法、组件通信、路由守卫、axios 拦截器。这套前端基础在任何公司做管理后台和 C 端页面都能用得上。2.2 开发环境搭建步骤我用的是 Linux 环境 VS Code你们用 Windows 或 macOS 也完全没问题。需要提前装好的软件有下面这几样Go 1.21 及以上版本去 Go 官网下载对应系统的安装包安装后终端执行go version验证MySQL 8.0本地安装或者用 Docker 起一个都行重点是记住 root 密码Redis 6.x 以上版本Windows 下可以下载 Redis 官方提供的 Windows 版本或使用 WSLNode.js 18 和 pnpm或者 npm用于前端工程装好之后建议先做一个自检MySQL 能通过命令行登录、Redis 能执行 ping 返回 PONG、Go 能正常编译 helloworld。三个环境变量检查过之后再开始建项目不然到时候出了问题你根本分不清是代码问题还是环境问题。2.3 初始化项目骨架新建一个项目目录比如叫lashou-mall里面放两个子目录server和web。server放后端 Go 代码web放前端 Vue3 代码。这样前后端分离从目录结构上就非常清晰。后端初始化命令mkdir server cd server go mod init lashou/mall go get -u gorm.io/gorm go get -u gorm.io/driver/mysql go get -u github.com/gin-gonic/gin go get -u github.com/go-redis/redis/v9 go get -u github.com/golang-jwt/jwt/v5 go get -u golang.org/x/crypto/bcrypt然后按下面的目录结构组织代码server/ ├── config/ # 配置文件与初始化 ├── controller/ # 接口层接收参数、返回响应 ├── service/ # 业务层处理核心逻辑 ├── repository/ # 数据层操作数据库 ├── model/ # 数据模型定义 ├── middleware/ # 中间件JWT、CORS、日志 ├── router/ # 路由注册 ├── utils/ # 工具函数JWT 生成、响应封装 └── main.go # 程序入口新手最容易犯的错误是把所有代码都堆在 main.go 里。分层确实会多写一点文件但后面加功能时你会感谢这个结构改接口时不用在密密麻麻的代码里找逻辑加表的时候直接新建 model 文件就行。前端初始化npm create vitelatest web -- --template vue cd web npm install npm install element-plus vue-router4 pinia axios好了环境准备好之后下一步是设计数据库。这是整个项目最核心的一趴我建议你不要跳过这个章节直接去看代码。3. 数据库设计与核心模型3.1 核心数据表结构设计我在设计表的时候坚持一个原则能不为追求复杂度引入的表一律不引入。下面这 6 张表是整个拉手网项目的骨架表名存储内容关键字段users用户账号信息id, username, password, nickname, phone, created_atmerchants商家账号信息id, name, contact_name, contact_phone, created_atproducts团购商品id, merchant_id, title, cover, desc, price, original_price, stock, sales, status, created_atorders订单主表id, order_no, user_id, product_id, merchant_id, coupon_id, amount, pay_amount, status, created_atcoupons优惠券id, name, type, amount, condition_amount, stock, start_time, end_timeuser_coupons用户优惠券id, user_id, coupon_id, status, received_at, used_at商品为什么不分 SKU 表因为我刻意控制了项目体量。如果一个团购商品的规格非常多不同时段、不同人数套餐那就需要拆商品 SPU 和 SKU 两层。但拉手网第一版做单规格就够了一个商品直接对应一个价格和一份库存。订单状态我设计成一个整数枚举0待支付1已支付2已取消3已完成已消费核销这个状态机后续所有模块都会用到。你可以在 model 里定义常量也可以在 README 里做注释但一定不要在代码里随便写裸数字。我见过太多同事写if order.Status 3然后过三个月自己都不记得 3 是什么意思。3.2 GORM 模型定义示例以用户和商品模型为例直接把 GORM 模型代码贴出来package model import time type User struct { ID uint gorm:primaryKey;autoIncrement json:id Username string gorm:size:32;uniqueIndex;not null json:username Password string gorm:size:128;not null json:- Nickname string gorm:size:32 json:nickname Phone string gorm:size:20 json:phone CreatedAt time.Time json:created_at UpdatedAt time.Time json:updated_at } type Product struct { ID uint gorm:primaryKey;autoIncrement json:id MerchantID uint gorm:index;not null json:merchant_id Title string gorm:size:128;not null json:title Cover string gorm:size:255 json:cover Desc string gorm:type:text json:desc Price float64 gorm:type:decimal(10,2);not null json:price OriginalPrice float64 gorm:type:decimal(10,2);not null json:original_price Stock int gorm:not null;default:0 json:stock Sales int gorm:not null;default:0 json:sales Status int gorm:not null;default:1 json:status CreatedAt time.Time json:created_at UpdatedAt time.Time json:updated_at }注意 User 的 Password 字段json:- 这表示序列化成 JSON 响应时永远不输出密码字段。这是个安全细节新手很容易漏。3.3 自动迁移与初始化连接用 GORM 连接 MySQL 的初始化代码放在 config 目录里package config import ( fmt gorm.io/driver/mysql gorm.io/gorm lashou/mall/model ) var DB *gorm.DB func InitDB() { dsn : root:123456tcp(127.0.0.1:3306)/lashou?charsetutf8mb4parseTimeTruelocLocal db, err : gorm.Open(mysql.Open(dsn), gorm.Config{}) if err ! nil { panic(连接数据库失败: err.Error()) } DB db // 自动迁移第一次运行自动建表 _ db.AutoMigrate(model.User{}, model.Product{}, model.Merchant{}, model.Order{}, model.Coupon{}, model.UserCoupon{}) }在你的机器上需要提前执行CREATE DATABASE lashou DEFAULT CHARSET utf8mb4;不然连接会报 unknown database。自动迁移能用但在生产环境我一般不用它因为线上表结构变更必须走 migration 脚本。这个项目作为练手用 AutoMigrate 已经足够了。4. 后端核心接口实现4.1 用户注册登录与 JWT 鉴权用户模块是入口先把注册登录实现好后面所有接口鉴权都依赖 JWT 中间件。注册的逻辑很简单从前端接收 username 和 password先检查用户名是否已存在然后使用 bcrypt 对密码做哈希再插入数据库。bcrypt 加盐是内置的不需要自己拼接盐值安全度比 MD5 高了不止一个量级。func Register(c *gin.Context) { var req struct { Username string json:username Password string json:password Nickname string json:nickname } if err : c.ShouldBindJSON(req); err ! nil { utils.Fail(c, 参数错误) return } var count int64 config.DB.Model(model.User{}).Where(username ?, req.Username).Count(count) if count 0 { utils.Fail(c, 用户名已存在) return } hashed, _ : bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) user : model.User{ Username: req.Username, Password: string(hashed), Nickname: req.Nickname, } if err : config.DB.Create(user).Error; err ! nil { utils.Fail(c, 注册失败) return } utils.Success(c, gin.H{id: user.ID}) }登录接口负责校验密码校验通过之后用用户 ID 和用户名签发 JWT token。token 有效期我设置为 24 小时过期后前端会收到 401然后引导用户重新登录。JWT 中间件的核心逻辑是从请求头 Authorization 里取出Bearer xxx这一段解析 token 并校验签名把解析出的用户 ID 塞进 gin.Context后续业务接口通过c.Get(userId)拿到当前登录用户。func JWTAuth() gin.HandlerFunc { return func(c *gin.Context) { tokenString : c.GetHeader(Authorization) if tokenString || !strings.HasPrefix(tokenString, Bearer ) { utils.Fail(c, 未登录) c.Abort() return } tokenString strings.TrimPrefix(tokenString, Bearer ) token, err : jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { return []byte(utils.JwtSecret), nil }) if err ! nil || !token.Valid { utils.Fail(c, 登录已过期) c.Abort() return } claims : token.Claims.(jwt.MapClaims) c.Set(userId, uint(claims[user_id].(float64))) c.Next() } }这里有一个新手特别容易踩的坑c.Set设置的 key 在后端内部是安全的但千万不要把密码、手机号这种敏感信息也塞进 token 的 payload 里。token 虽然签名防篡改但 payload 是 base64 编码任何人解码都能看到内容。4.2 商品列表与详情接口商品列表接口是典型的 C 端查询接口带着分页和分类筛选。我直接给出最精简可用的版本func ProductList(c *gin.Context) { page, _ : strconv.Atoi(c.DefaultQuery(page, 1)) pageSize, _ : strconv.Atoi(c.DefaultQuery(page_size, 10)) keyword : c.Query(keyword) query : config.DB.Model(model.Product{}).Where(status 1) if keyword ! { query query.Where(title LIKE ?, %keyword%) } var total int64 query.Count(total) var products []model.Product query.Order(id DESC). Offset((page - 1) * pageSize). Limit(pageSize). Find(products) utils.Success(c, gin.H{ list: products, total: total, }) }库存和销量字段在商品列表里直接返回没有太大问题但在详情页我更建议把库存字段隐藏掉只返回剩余量提示。原因很实际库存本身是动态变化的前端也没必要展示精确数字让用户产生焦虑。商品详情接口只需要一个参数 product_id直接查库返回。接口写好后用浏览器访问/api/v1/products?page1page_size10应该能看到 JSON 数据了。如果你能走到这一步说明数据库、Gin 路由、模型层的链路都是通的。4.3 下单流程与事务处理下单是订单模块的核心接口也是整个项目里技术含量最高的一段代码。它的难点不是代码本身而是你必须保证库存不能超卖、订单数据必须完整落库、优惠券不能重复使用。我的实现思路是使用数据库事务。事务里先锁定商品行SELECT ... FOR UPDATE检查库存是否充足扣减库存并增加销量然后创建订单记录最后统一提交。用悲观锁的原因是团购业务并发量没那么夸张而且对新手来说悲观锁的概念比乐观锁更容易理解。代码如下func CreateOrder(c *gin.Context) { userId : c.GetUint(userId) var req struct { ProductID uint json:product_id CouponID uint json:coupon_id,omitempty } if err : c.ShouldBindJSON(req); err ! nil { utils.Fail(c, 参数错误) return } err : config.DB.Transaction(func(tx *gorm.DB) error { // 1. 锁定商品行 var product model.Product if err : tx.Clauses(clause.Locking{Strength: UPDATE}). First(product, req.ProductID).Error; err ! nil { return errors.New(商品不存在) } if product.Stock 0 { return errors.New(库存不足) } // 2. 扣减库存 if err : tx.Model(product). Update(stock, gorm.Expr(stock - ?, 1)). Update(sales, gorm.Expr(sales ?, 1)).Error; err ! nil { return err } // 3. 创建订单 order : model.Order{ OrderNo: utils.GenerateOrderNo(), UserID: userId, ProductID: product.ID, MerchantID: product.MerchantID, Amount: product.Price, PayAmount: product.Price, Status: 0, } if req.CouponID 0 { // 检查优惠券是否可用并优惠付款金额 var uc model.UserCoupon if err : tx.Where(id ? AND user_id ? AND status 0, req.CouponID, userId). First(uc).Error; err ! nil { return errors.New(优惠券不可用) } order.PayAmount order.Amount - 20 // 简化逻辑实际按券面值计算 if order.PayAmount 0 { order.PayAmount 0 } tx.Model(uc).Update(status, 1) } return tx.Create(order).Error }) if err ! nil { utils.Fail(c, err.Error()) return } utils.Success(c, gin.H{message: 下单成功}) }这段代码里有两个细节值得说。第一个是clause.Locking{Strength: UPDATE}它生成的 SQL 是SELECT * FROM products WHERE id ? FOR UPDATE会在事务期间锁住这行记录防止并发时两个请求同时读到库存 1 然后都扣减成功。第二个是订单号生成我没用数据库自增 ID因为自增 ID 容易暴露业务量而且多表叠加时可能冲突。我当时写了一个时间戳加随机数的工具函数足够这个项目使用。订单状态机的流转也很重要。支付成功后状态从 0 变 1用户主动取消或超时未支付则从 0 变 2到店核销后从 1 变 3。状态的跳转应该在 service 层封装成方法避免在 controller 里散落各种裸更新 SQL。4.4 优惠券的领取与核销优惠券模块虽然简单却涉及一个所有电商平台都绕不开的思维资格校验与状态变更的一致性。领取接口的逻辑是先确认优惠券还在发放期内剩余数量大于 0然后检查当前用户是否已经领过这张券防止刷券最后在数据库事务里把优惠券数量减一并往 user_coupons 表插入一条关联记录。整个过程同样必须放在事务里否则会出现发出去的券比库存多的脏数据。我在设计时给 user_coupons 表加了一个唯一索引(user_id, coupon_id)这样即使并发请求同时进来数据库层也能直接拦截重复领取。这是实战里一个非常典型的兜底姿势业务校验负责正常流程唯一索引负责极端并发。校验优惠券是否可用时需要同时判断有效期和最低使用门槛。团购领域常用的玩法是满减券满 100 减 20。那么下单时就要检查订单金额是否达到condition_amount门槛再决定是否能使用。这部分逻辑我在下单事务里的简写已经展示了实际你可以做得更细一点把优惠金额也存进订单表方便后续对账。5. 前端页面实现与前后端联调5.1 Vue3 工程搭建与路由设计前端工程我建议一开始就配置好路由不然页面一多就乱。用 Vue Router 的 createWebHistory 模式然后做一套带布局的嵌套路由。首页和商品列表可以共用一个 layout登录注册页用独立的空布局。路由设计如下/login 登录页 /register 注册页 / 首页商品列表需要登录才能访问 /product/:id 商品详情页 /order/confirm 订单确认页带商品 ID 和优惠券选择 /orders 我的订单列表 /merchant/products 商家商品管理 /merchant/orders 商家订单管理首页、商品详情、下单这些页面都需要登录所以在路由配置里加一个全局前置守卫。每次跳转前检查 localStorage 里有没有 token没有就重定向到 /loginrouter.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })5.2 核心页面组件划分首页商品列表我做了三个组件顶部的搜索栏、筛选区、商品卡片列表。商品卡片展示封面图、标题、团购价和原价以及已售数量。这些都是最基础的 Vue 组件通信父组件负责请求接口数据子组件只负责展示通过 props 把商品数据传下去。商品详情页稍微复杂一点除了展示基本信息还要展示购买数量选择器、优惠券选择弹窗、立即购买按钮。这个页面建议你认真写一写因为它很锻炼组件拆分能力商品信息卡、门店信息卡、购买操作栏可以拆成三个叶子组件。订单确认页的核心交互是从商品详情页带商品 ID 过来页面加载商品信息、加载用户可用优惠券列表用户选择优惠券后前端实时计算支付金额。这个页面的前端逻辑非常接近真实电商做完之后你会对“前端状态管理”有更直观的理解。5.3 axios 封装与拦截器axios 封装是前端工程质量的关键。我把 axios 实例单独放到src/utils/request.js里统一做三件事请求时自动带上 token、响应时统一处理错误码、网络异常时统一提示。import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api/v1, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } ) export default request统一封装的好处非常明显你不需要在每个业务页面里重复写错误提示代码后端返回的任何错误都能被用户看到而且 401 时自动踢回登录页不会出现页面白屏的尴尬。5.4 前后端跨域与联调前后端分离开发时最常见的拦路虎就是跨域。Vite 开发服务器默认跑在 5173 端口后端跑在 8080 端口。最简单的解决方案是在 Vite 配置文件里设置代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/v1/products时Vite 会把请求转发到http://localhost:8080/api/v1/products浏览器端完全无感知也就不会触发浏览器跨域限制。除了代理还要注意后端是否配置了 CORS 中间件。如果你将来直接部署到同一域名下这个问题就不存在但开发阶段两个方式都做上能少踩很多莫名其妙的坑。我的联调经验是先把后端接口用 Apifox 或 Postman 全部跑通再对接前端。这样一旦页面数据不对你能快速判断问题出在前端还是后端。具体排查优先级是先看 Network 里请求是否发出再看请求地址和参数是否正确最后看后端响应内容。6. 秒杀抢购场景实战6.1 秒杀场景的技术难点在哪如果你想在简历里写“高并发”三个字秒杀几乎是绕不开的项目经验。但很多新手对秒杀有误区以为就是用 Redis 缓存一下就行。实际上秒杀真正的难点在于高并发下既要保证系统不被打崩又要保证不能超卖还不能漏单。拉手网这类团购平台的活动页用户点击抢购瞬间QPS 可能冲得很高。如果直接用 MySQL 的扣减库存接口去扛数据库连接池会瞬间被打满整个系统直接雪崩。但如果只依赖 Redis 做扣减又可能因为 Redis 挂了导致数据不一致。我的做法是分两阶段先用 Redis 做库存预扣再把成功的请求异步落库。这样既能挡住绝大多数无效请求又能保证最终数据完整。6.2 Redis 预减库存的核心代码秒杀接口的入口先调用一个 Lua 脚本把扣减库存和校验用户是否已经购买这两个动作放在原子操作里执行。Redis 的 DECR 命令本身是原子的但如果用户重复抢购单纯 DECR 会让同一个用户买好几份所以要加一个用户维度去重。func Seckill(c *gin.Context) { userId : c.GetUint(userId) productId, _ : strconv.Atoi(c.PostForm(product_id)) // Lua 脚本检查用户是否已买过再扣减库存 script : if redis.call(SISMEMBER, KEYS[2], ARGV[1]) 1 then return 0 end local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock 0 then return -1 end redis.call(DECR, KEYS[1]) redis.call(SADD, KEYS[2], ARGV[1]) return 1 ctx : context.Background() result, err : config.RDB.Eval(ctx, script, []string{ seckill:stock: strconv.Itoa(productId), seckill:users: strconv.Itoa(productId), }, userId).Int() if err ! nil { utils.Fail(c, 系统繁忙) return } if result 0 { utils.Fail(c, 您已经抢购过了) return } if result -1 { utils.Fail(c, 已被抢光) return } // 预扣成功放入异步队列落库 asyncCreateOrder(userId, productId) utils.Success(c, gin.H{message: 抢购成功正在生成订单}) }这段代码看起来不复杂但它很好的控制了流量高并发请求进来后只有真正抢到库存的用户才能触达数据库大量无效请求在 Redis 层直接返回。数据库面对的写压力会小一个数量级以上。6.3 异步落库与订单生成异步落库我用的是最简单也最容易理解的姿势一个有缓冲的 channel 加一组 worker goroutine。抢购请求成功后把 user_id 和 product_id 放进 channel后台 worker 消费 channel 里的数据执行真正的数据库事务落单。这个方案不依赖外部组件单机就能跑通新手理解起来非常直观。var orderChan make(chan OrderMessage, 10000) func asyncCreateOrder(userId, productId uint) { orderChan - OrderMessage{UserID: userId, ProductID: productId} } func StartOrderWorker() { for i : 0; i 10; i { go func() { for msg : range orderChan { createOrderFromSeckill(msg.UserID, msg.ProductID) } }() } }worker 内部执行的 createOrderFromSeckill 和普通的下单逻辑类似但不需要再走 Redis 预扣了。因为 Redis 才会做库存判断数据库层只需要把订单记录落下来。如果此时数据库事务失败比如商品被商家下架了可以记录日志并调用补偿逻辑把用户的库存“还”回去也就是重新 INCR 一下 Redis 库存。这个方案解决了刷爆数据库的问题但它的局限也很明显channel 里的消息是内存态服务重启会丢失。生产环境一般会用 RabbitMQ、Kafka 或 Redis Stream。我建议你先把 channel 方案跑通理解思想之后再考虑引入消息队列。6.4 前端抢购页面的配合秒杀页前端需要做一个倒计时按钮活动未开始时显示“即将开始”倒计时结束后按钮变为“立即抢购”。抢购成功之后不能直接跳转支付页而是要提示用户“订单正在生成中”然后轮询订单列表接口等到那条秒杀订单状态变成待支付再显示去支付按钮。我在第一次实现时直接让前端等待接口返回订单号结果抢购高峰期接口超时用户体验很差。后来改成异步轮询虽然逻辑多了一步但整体稳定得多。这里也建议你在前端做一个抢购按钮防重复点击的处理点击后按钮置灰 3 秒防止用户手抖连点导致重复请求。7. 常见问题排查与部署技巧7.1 新手最容易踩的坑我把两次搭建过程中真实踩过的、以及身边人经常问我的问题整理成一张速查表遇到问题时优先查这里问题现象可能原因解决办法后端启动报Error 1049: Unknown databaseMySQL 里没有创建 lashou 库执行CREATE DATABASE lashou DEFAULT CHARSET utf8mb4;接口返回 401但明明调了登录接口JWT secret 不一致或 token 过期检查 utils.JwtSecret 是否为同一个值过期就重新登录前端请求接口报 CORS 错误没配代理或没配后端 CORS 中间件优先使用 Vite proxy 方式解决Redis 连接报connection refusedRedis 服务没启动或地址端口不对确认 6379 端口可达执行redis-cli ping数据库写入中文乱码表字符集不是 utf8mb4建库时指定DEFAULT CHARSET utf8mb4DSN 里加charsetutf8mb4并发下单时库存扣成负数扣库存没有加锁或没用事务下单方法里加clause.Locking{Strength: UPDATE}GORM 查询结果为空但数据库有数据表名自动复数化导致查错表检查模型名或在 gorm.Config 里设置NamingStrategy另外一个非常重要的点是当你改 Go 代码时一定要重新编译再重启进程。Gin 没有热加载能力新手经常改了代码却发现接口没变化最后发现是忘了重新go run。你可以用air工具做热重载也可以每次手动操作但一定要清楚进程必须重启才生效。7.2 项目上线部署实操项目开发完下一步是把它部署到服务器上。新手一般没有现成的服务器我建议先用 Docker Compose 在本地模拟整个部署编排后续再迁移到云服务器。我推荐的最简部署结构是version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: lashou ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql redis: image: redis:7 ports: - 6379:6379 backend: build: ./server ports: - 8080:8080 depends_on: - mysql - redis frontend: build: ./web ports: - 80:80 depends_on: - backend volumes: mysql_data:后端 Dockerfile 的核心是把编译好的二进制文件复制到一个精简镜像里这样镜像体积非常小启动速度快FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN go mod download RUN go build -o lashou . FROM alpine:latest WORKDIR /root/ COPY --frombuilder /app/lashou . EXPOSE 8080 CMD [./lashou]前端 Dockerfile 用 node 镜像构建静态文件然后用 nginx 镜像托管同时把/api路径反向代理到后端服务server { listen 80; location / { root /usr/share/nginx/html; index index.html; } location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }整个编排跑起来之后你访问服务器 IP 的 80 端口就能看到前端页面前端所有/api请求都会被 nginx 转发到后端容器后端容器通过内网访问 MySQL 和 Redis 容器。这套结构在生产环境也足够支撑小规模流量。7.3 项目后续可以怎么扩展这个项目做完你已经有了一份非常好的全栈作品。如果求职方向是后端开发建议往下面几个方向扩展把 channel 异步落库替换成 RabbitMQ 或 Kafka并补充重试机制和死信队列给订单模块接入真实的支付渠道微信支付 / 支付宝重点处理支付回调的签名校验和幂等性给商品模块增加缓存策略用 Redis 缓存热点商品详情并处理缓存与数据库的一致性问题把商家后台做成完整的管理系统增加数据统计、图表报表、操作日志模块用 Docker Compose 或 K8s 做一套完整的 CI/CD 流水线实现代码提交后自动构建部署我个人强烈建议你做完主流程后挑一到两个方向深挖。面试时你不用把所有细节都讲明白哪怕只把秒杀限流和缓存一致性这两个点讲透就已经能证明你具备真实项目开发中的思考能力。最后分享一条私人心得新手做项目最大的障碍不是代码不会写而是总想着一口气把所有功能都做完。我的建议是先跑通最基础的一条流水注册、登录、发布商品、浏览商品、下单、查看订单。这条链路通了项目就已经成功了百分之八十。剩下的优惠券、秒杀、商户后台都是锦上添花可以按兴趣和精力逐步加。把这个项目完整写完一遍你对 Go 后端开发、数据库设计、缓存使用和前端协作的理解会比看十篇教程都更扎实。
返回列表