阶段3: 核心业务逻辑
解决酒店预订系统的三个核心难题:并发安全(防超卖)、订单状态机、支付集成。完成后系统能在并发场景下正确运行,订单状态流转完整,支付和退款流程闭环。
| 模块 | 核心知识点 | 在本阶段的作用 |
|---|---|---|
| Module 6 | ACID / 悲观锁 / Saga | 事务隔离、SELECT FOR UPDATE 防超卖 |
| Module 7 | 幂等性 | 支付回调的幂等处理 |
| Module 5 | 异步处理 | 退款异步化、通知异步化 |
| Module 17 | 支付集成 | Stripe Payment Intent + Webhook |
步骤1: 并发安全 — 防超卖
Section titled “步骤1: 并发安全 — 防超卖”先演示问题,再用悲观锁解决。
1.1 演示超卖问题
Section titled “1.1 演示超卖问题”你是一名 Go 后端工程师。我的酒店预订系统有超卖问题。
## 当前代码问题
创建预订时,我先查库存再扣减:1. SELECT available_count FROM inventory WHERE room_type_id = ? AND date = ?2. 检查 available_count > 03. UPDATE inventory SET available_count = available_count - 1 WHERE ...
两个请求同时执行时:- 请求A 读到 available_count = 1- 请求B 读到 available_count = 1- 请求A 扣减成功,available_count = 0- 请求B 也扣减成功,available_count = -1→ 超卖了!
## 请做以下事情
### 1. 写一个并发测试脚本(Go test)
文件:server/handlers/booking_test.go
测试场景:- 某房型某日只有 1 间可用- 同时发起 10 个预订请求(用 goroutine)- 断言:只有 1 个请求成功,其余 9 个失败- 断言:available_count 最终为 0(不是负数)
```gofunc TestConcurrentBooking(t *testing.T) { // 设置测试数据:1间可用 // 启动10个 goroutine 同时预订 // 统计成功和失败数量 // 断言结果}2. 运行测试,证明当前代码会超卖
Section titled “2. 运行测试,证明当前代码会超卖”给出运行命令和预期输出(available_count 变成负数)。
请输出完整的测试代码。
#### 1.2 用 SELECT FOR UPDATE 修复你是一名 Go+GORM 后端工程师。
请修改 server/handlers/booking.go 中的创建预订逻辑,用悲观锁(SELECT FOR UPDATE)解决超卖问题。
将创建预订的事务逻辑改为:
err := db.Transaction(func(tx *gorm.DB) error { // 1. 锁定库存行(SELECT FOR UPDATE) var inventories []models.Inventory err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("room_type_id = ? AND date >= ? AND date < ?", req.RoomTypeID, checkIn, checkOut). Order("date ASC"). Find(&inventories).Error if err != nil { return err }
// 2. 检查天数是否匹配 if len(inventories) != nights { return errors.New("部分日期无库存记录") }
// 3. 检查每天是否有房 var totalPrice int64 for _, inv := range inventories { if inv.AvailableCount <= 0 { return errors.New("所选日期无可用房间") } totalPrice += inv.Price }
// 4. 扣减库存(逐条更新,确保 affected rows = 1) for _, inv := range inventories { result := tx.Model(&models.Inventory{}). Where("id = ? AND available_count > 0", inv.ID). Update("available_count", gorm.Expr("available_count - 1")) if result.RowsAffected != 1 { return errors.New("库存扣减失败,可能已被其他人预订") } }
// 5. 创建订单 order := models.Order{ ID: uuid.New().String(), UserID: req.UserID, HotelID: req.HotelID, RoomTypeID: req.RoomTypeID, CheckInDate: checkIn, CheckOutDate: checkOut, GuestName: req.GuestName, GuestPhone: req.GuestPhone, Nights: nights, TotalPrice: totalPrice, Status: "pending", } return tx.Create(&order).Error})请在代码注释中解释:
FOR UPDATE的作用:对查询到的行加排他锁,其他事务读同样的行会阻塞等待- 为什么要在事务内查询和更新:保证原子性
- 为什么扣减时还要检查
available_count > 0:双重保险,即使锁失效也不会超卖 - 这个方案的缺点:高并发下锁等待会影响性能(后续可用乐观锁/Redis预扣优化)
请输出修改后的完整 booking.go 代码。
#### 1.3 再次运行并发测试请更新之前的并发测试 server/handlers/booking_test.go,确保:
- 测试使用真实数据库(测试数据库,非生产)
- 测试前清理并重建测试数据
- 运行 10 个并发预订
- 断言:
- 恰好 1 个成功,9 个失败
- available_count 最终为 0
- 订单表中只有 1 条记录
- 测试后清理数据
同时给出运行命令: go test -v -run TestConcurrentBooking ./handlers/
预期输出:成功1个,失败9个,available_count = 0,PASS。
请输出完整的测试代码。
### 步骤2: 订单状态机你是一名 Go 后端工程师。请为酒店预订系统实现订单状态机。
pending → confirmed (支付成功)pending → cancelled (用户取消 / 超时取消)confirmed → completed (退房完成)confirmed → cancelled (用户取消,需退款)非法转换(例如 completed → pending)应该被拒绝。
请创建 server/models/order_status.go
Section titled “请创建 server/models/order_status.go”package models
// 订单状态常量const ( OrderStatusPending = "pending" OrderStatusConfirmed = "confirmed" OrderStatusCancelled = "cancelled" OrderStatusCompleted = "completed")
// 合法状态转换表var validTransitions = map[string][]string{ OrderStatusPending: {OrderStatusConfirmed, OrderStatusCancelled}, OrderStatusConfirmed: {OrderStatusCompleted, OrderStatusCancelled}, // completed 和 cancelled 是终态,不能再转换}
// CanTransition 检查状态转换是否合法func CanTransition(from, to string) bool { targets, ok := validTransitions[from] if !ok { return false } for _, t := range targets { if t == to { return true } } return false}
// TransitionOrder 执行状态转换(在事务中使用)// 返回错误如果转换不合法func TransitionOrder(tx *gorm.DB, orderID string, toStatus string) error { // 1. 查询当前订单(SELECT FOR UPDATE 防并发) // 2. 检查 CanTransition // 3. 更新状态 // 4. 如果转为 cancelled,设置 cancelled_at}同时更新 handlers
Section titled “同时更新 handlers”- 创建预订后状态为 pending
- 支付成功回调 → 调用 TransitionOrder(tx, orderID, “confirmed”)
- 取消订单 → 调用 TransitionOrder(tx, orderID, “cancelled”)
- 退房 → 调用 TransitionOrder(tx, orderID, “completed”)
还需要:超时自动取消
Section titled “还需要:超时自动取消”创建一个后台任务(goroutine),每分钟扫描:
- 状态为 pending 且 created_at 超过 30 分钟的订单
- 自动取消并恢复库存
文件:server/tasks/order_timeout.go
请输出所有文件的完整代码。
### 步骤3: 支付集成 (Stripe)你是一名 Go 后端工程师,熟悉 Stripe 支付集成。
请为酒店预订系统集成 Stripe 支付。
- 用户创建订单 → 状态 pending
- 前端调用后端获取 Payment Intent client_secret
- 前端用 Stripe.js 完成支付
- Stripe 通过 Webhook 通知后端支付结果
- 后端验证 Webhook 签名,更新订单状态为 confirmed
请创建以下文件
Section titled “请创建以下文件”1. server/handlers/payment.go
Section titled “1. server/handlers/payment.go”POST /api/v1/bookings/:id/pay
Section titled “POST /api/v1/bookings/:id/pay”创建 Stripe Payment Intent:
func CreatePaymentIntent(c *gin.Context) { orderID := c.Param("id")
// 1. 查询订单,确认状态为 pending // 2. 创建 Payment Intent params := &stripe.PaymentIntentParams{ Amount: stripe.Int64(order.TotalPrice), // 分 Currency: stripe.String("cny"), Metadata: map[string]string{ "order_id": order.ID, }, } // 3. 保存 payment_intent_id 到订单 // 4. 返回 client_secret 给前端}响应:
{ "data": { "client_secret": "pi_xxx_secret_xxx", "order_id": "uuid-xxx", "amount": 40000, "amount_display": "400.00" }}POST /api/v1/webhooks/stripe
Section titled “POST /api/v1/webhooks/stripe”处理 Stripe Webhook:
func HandleStripeWebhook(c *gin.Context) { // 1. 读取请求体 // 2. 验证 Webhook 签名(stripe.ConstructEvent) // 3. 解析事件类型
switch event.Type { case "payment_intent.succeeded": // 从 metadata 获取 order_id // 幂等检查:如果订单已经是 confirmed,直接返回 200 // 调用 TransitionOrder → confirmed
case "payment_intent.payment_failed": // 记录失败日志 // 可选:通知用户重试 }
// 4. 返回 200(必须快速返回,否则 Stripe 会重试)}2. 幂等处理
Section titled “2. 幂等处理”Stripe Webhook 可能重复发送(网络问题导致 Stripe 认为你没收到)。 必须做幂等处理:
- 检查订单当前状态,如果已经 confirmed,直接返回 200 不做任何操作
- 用 event.ID 做去重(可选,存到 Redis 或数据库)
3. server/.env.example 追加
Section titled “3. server/.env.example 追加”STRIPE_SECRET_KEY=sk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx
4. 前端支付页面 web/src/pages/PaymentPage.jsx
Section titled “4. 前端支付页面 web/src/pages/PaymentPage.jsx”- 调用 POST /api/v1/bookings/:id/pay 获取 client_secret
- 使用 @stripe/stripe-js 和 @stripe/react-stripe-js
- 展示 Stripe CardElement
- 支付成功后跳转到订单详情页
- 支付失败显示错误信息
请输出所有文件的完整代码。包含必要的错误处理和日志。
### 步骤4: 取消与退款你是一名 Go 后端工程师,熟悉 Stripe。
请实现酒店预订系统的取消和退款逻辑。
- pending 状态取消:免费取消,无需退款(因为还没付款)
- confirmed 状态取消:
- 入住日期 ≥ 3天:全额退款
- 入住日期 < 3天:扣除第一晚房费,退还剩余
- 入住当天:不可取消
请修改 server/handlers/booking.go 的取消逻辑
Section titled “请修改 server/handlers/booking.go 的取消逻辑”POST /api/v1/bookings/:id/cancel
Section titled “POST /api/v1/bookings/:id/cancel”func CancelBooking(c *gin.Context) { orderID := c.Param("id")
err := database.DB.Transaction(func(tx *gorm.DB) error { // 1. 查询订单(SELECT FOR UPDATE) var order models.Order tx.Set("gorm:query_option", "FOR UPDATE").First(&order, "id = ?", orderID)
// 2. 检查是否可取消 if order.Status == "completed" || order.Status == "cancelled" { return errors.New("订单不可取消") }
// 3. 如果是 confirmed,计算退款金额 var refundAmount int64 if order.Status == "confirmed" { daysUntilCheckIn := int(order.CheckInDate.Sub(time.Now()).Hours() / 24)
if daysUntilCheckIn < 0 { return errors.New("已过入住日期,不可取消") } else if daysUntilCheckIn >= 3 { refundAmount = order.TotalPrice // 全额退款 } else if daysUntilCheckIn >= 1 { // 扣除第一晚,退剩余 firstNightPrice := order.TotalPrice / int64(order.Nights) refundAmount = order.TotalPrice - firstNightPrice } else { return errors.New("入住当天不可取消") } }
// 4. 状态转换 err := models.TransitionOrder(tx, orderID, "cancelled") if err != nil { return err }
// 5. 恢复库存 tx.Model(&models.Inventory{}). Where("room_type_id = ? AND date >= ? AND date < ?", order.RoomTypeID, order.CheckInDate, order.CheckOutDate). Update("available_count", gorm.Expr("available_count + 1"))
// 6. 如果需要退款,异步发起 if refundAmount > 0 { go processRefund(order.PaymentID, refundAmount) }
return nil })}退款函数(异步执行)
Section titled “退款函数(异步执行)”func processRefund(paymentIntentID string, amount int64) { // 1. 调用 Stripe Refund API params := &stripe.RefundParams{ PaymentIntent: stripe.String(paymentIntentID), Amount: stripe.Int64(amount), } refund, err := refund.New(params)
// 2. 记录退款结果到数据库 // 3. 如果失败,记录日志并重试(最多3次) // 4. 如果重试都失败,标记为需要人工处理}同时创建退款记录表 server/models/refund.go
Section titled “同时创建退款记录表 server/models/refund.go”字段:ID, OrderID, PaymentIntentID, Amount, Status(pending/succeeded/failed), StripeRefundID, Reason, RetryCount, CreatedAt, UpdatedAt
更新取消 API 的响应
Section titled “更新取消 API 的响应”{ "data": { "order_id": "uuid-xxx", "status": "cancelled", "refund": { "amount": 40000, "amount_display": "400.00", "status": "pending", "message": "退款将在3-5个工作日到账" } }}如果是 pending 状态取消(无需退款),refund 字段为 null。
请输出所有修改和新增文件的完整代码。
---
## 检查清单
- [ ] 并发测试通过:10个并发预订只有1个成功- [ ] available_count 永远不会变成负数- [ ] 订单状态只能按合法路径转换(pending→confirmed→completed)- [ ] 非法状态转换(如 completed→pending)会被拒绝并返回错误- [ ] 超时未支付的订单(30分钟)自动取消并恢复库存- [ ] Stripe Webhook 重复调用不会重复确认订单(幂等)- [ ] 取消退款金额按政策正确计算(≥3天全额,<3天扣首晚)
---
## 常见踩坑
1. **SELECT FOR UPDATE 忘记在事务中使用** — `FOR UPDATE` 必须在事务内才有效。如果不在事务中,锁会立即释放,等于没锁。GORM 中必须用 `db.Transaction(func(tx *gorm.DB) error { ... })` 包裹,并且在 `tx` 上执行查询,不是 `db`。
2. **退款在事务中同步调用 Stripe API** — Stripe API 调用可能耗时几秒,如果放在数据库事务中,会长时间持有行锁。正确做法:事务中只做状态变更和库存恢复,退款用 `go processRefund(...)` 异步执行。
3. **Webhook 没有验证签名** — 不验证签名意味着任何人都能伪造支付成功通知。必须用 `stripe.ConstructEvent(body, sig, webhookSecret)` 验证。测试时用 `stripe listen --forward-to localhost:8080/api/v1/webhooks/stripe` 转发事件。
4. **超时取消任务没有处理库存恢复** — 自动取消订单时如果忘了恢复库存,那些房间就永久"消失"了。取消逻辑(无论手动还是自动)必须走同一个函数,确保库存恢复不会被遗漏。
5. **退款金额计算用除法取整数** — `TotalPrice / Nights` 可能不能整除。例如 3 晚 ¥1000(100000分),每晚 33333.33分。建议:第一晚价格从 Inventory 表查询实际价格,而不是简单除法。