跳转到内容

阶段3: 核心业务逻辑

解决酒店预订系统的三个核心难题:并发安全(防超卖)、订单状态机、支付集成。完成后系统能在并发场景下正确运行,订单状态流转完整,支付和退款流程闭环。


模块核心知识点在本阶段的作用
Module 6ACID / 悲观锁 / Saga事务隔离、SELECT FOR UPDATE 防超卖
Module 7幂等性支付回调的幂等处理
Module 5异步处理退款异步化、通知异步化
Module 17支付集成Stripe Payment Intent + Webhook

先演示问题,再用悲观锁解决。

你是一名 Go 后端工程师。我的酒店预订系统有超卖问题。
## 当前代码问题
创建预订时,我先查库存再扣减:
1. SELECT available_count FROM inventory WHERE room_type_id = ? AND date = ?
2. 检查 available_count > 0
3. 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(不是负数)
```go
func 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
})

请在代码注释中解释:

  1. FOR UPDATE 的作用:对查询到的行加排他锁,其他事务读同样的行会阻塞等待
  2. 为什么要在事务内查询和更新:保证原子性
  3. 为什么扣减时还要检查 available_count > 0:双重保险,即使锁失效也不会超卖
  4. 这个方案的缺点:高并发下锁等待会影响性能(后续可用乐观锁/Redis预扣优化)

请输出修改后的完整 booking.go 代码。

#### 1.3 再次运行并发测试

请更新之前的并发测试 server/handlers/booking_test.go,确保:

  1. 测试使用真实数据库(测试数据库,非生产)
  2. 测试前清理并重建测试数据
  3. 运行 10 个并发预订
  4. 断言:
    • 恰好 1 个成功,9 个失败
    • available_count 最终为 0
    • 订单表中只有 1 条记录
  5. 测试后清理数据

同时给出运行命令: go test -v -run TestConcurrentBooking ./handlers/

预期输出:成功1个,失败9个,available_count = 0,PASS。

请输出完整的测试代码。

### 步骤2: 订单状态机

你是一名 Go 后端工程师。请为酒店预订系统实现订单状态机。

pending → confirmed (支付成功)
pending → cancelled (用户取消 / 超时取消)
confirmed → completed (退房完成)
confirmed → cancelled (用户取消,需退款)

非法转换(例如 completed → pending)应该被拒绝。

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
}
  1. 创建预订后状态为 pending
  2. 支付成功回调 → 调用 TransitionOrder(tx, orderID, “confirmed”)
  3. 取消订单 → 调用 TransitionOrder(tx, orderID, “cancelled”)
  4. 退房 → 调用 TransitionOrder(tx, orderID, “completed”)

创建一个后台任务(goroutine),每分钟扫描:

  • 状态为 pending 且 created_at 超过 30 分钟的订单
  • 自动取消并恢复库存

文件:server/tasks/order_timeout.go

请输出所有文件的完整代码。

### 步骤3: 支付集成 (Stripe)

你是一名 Go 后端工程师,熟悉 Stripe 支付集成。

请为酒店预订系统集成 Stripe 支付。

  1. 用户创建订单 → 状态 pending
  2. 前端调用后端获取 Payment Intent client_secret
  3. 前端用 Stripe.js 完成支付
  4. Stripe 通过 Webhook 通知后端支付结果
  5. 后端验证 Webhook 签名,更新订单状态为 confirmed

创建 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"
}
}

处理 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 会重试)
}

Stripe Webhook 可能重复发送(网络问题导致 Stripe 认为你没收到)。 必须做幂等处理:

  • 检查订单当前状态,如果已经 confirmed,直接返回 200 不做任何操作
  • 用 event.ID 做去重(可选,存到 Redis 或数据库)

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 的取消逻辑”
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
})
}
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

{
"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 表查询实际价格,而不是简单除法。