跳转到内容

阶段2: MVP实现

实现最小可用版本:用户能搜索酒店、查看详情、创建预订、取消预订。前后端都能跑起来,数据能从数据库到页面完整流通。


模块核心知识点在本阶段的作用
Module 2关系模型 / 索引GORM 模型定义、基础索引
Module 1REST / 分页API 端点设计、分页实现
Module 16Docker用 Docker Compose 跑 PostgreSQL + Redis
Module 13HTTP / CORSVite 代理、跨域配置

创建项目目录结构、初始化 Go 和 React 项目、配置 Docker。

你是一名全栈工程师,请帮我初始化一个酒店预订系统项目。
## 项目名称
hotel-reservation
## 要求
### 1. 创建目录结构

hotel-reservation/ ├── server/ # Go 后端 │ ├── main.go │ ├── .env.example │ ├── database/ │ │ └── database.go │ ├── models/ │ ├── handlers/ │ ├── middleware/ │ ├── seed/ │ │ └── seed.go │ └── go.mod ├── web/ # React 前端 │ ├── src/ │ │ ├── api/ │ │ │ └── client.js │ │ ├── pages/ │ │ ├── components/ │ │ └── App.jsx │ ├── index.html │ ├── vite.config.js │ └── package.json ├── docker-compose.yml ├── Taskfile.yaml └── .gitignore

### 2. docker-compose.yml
- PostgreSQL 15,端口 5432,数据库名 hotel_reservation
- Redis 7,端口 6379
- 都挂载 volume 持久化
### 3. server/main.go
- 加载 .env
- 连接数据库(调用 database.Connect())
- AutoMigrate 所有模型
- 运行 seed(如果环境变量 RUN_SEED=true)
- 注册路由
- 启动 Gin 在 :8080
### 4. server/database/database.go
- 用 GORM 连接 PostgreSQL
- 导出 DB 变量
### 5. server/.env.example
DATABASE_URL=postgres://postgres:postgres@localhost:5432/hotel_reservation?sslmode=disable
JWT_SECRET=dev-secret-change-me
RUN_SEED=true
### 6. web/vite.config.js
- 代理 /api 到 localhost:8080
### 7. Taskfile.yaml
- task server: cd server && go run .
- task web: cd web && npm run dev
- task seed: RUN_SEED=true task server
- task infra: docker-compose up -d
### 8. .gitignore
- server/.env
- web/node_modules
- web/dist
请输出所有文件的完整内容,可以直接复制使用。最后给出首次运行的命令顺序。

定义 GORM 模型并创建种子数据。

你是一名 Go 后端工程师,使用 GORM + PostgreSQL。
请创建以下文件:
## 1. server/models/user.go
```go
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Name string `gorm:"size:100;not null" json:"name"`
Phone string `gorm:"size:20" json:"phone"`
Role string `gorm:"size:20;default:'guest';not null" json:"role"` // guest, hotel_admin, admin
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

包含字段:ID, Name, Address, City, Province, Country, Latitude, Longitude, Description, Rating, ImageURL, OwnerID(FK→User), Status(active/inactive), CreatedAt, UpdatedAt 加索引:city, status, (city + status) 联合索引

包含字段:ID, HotelID(FK→Hotel), Name, Description, MaxGuests, BedType, Area(平方米), BasePrice(int64,分), Amenities(JSON/datatypes.JSON), ImageURL, CreatedAt, UpdatedAt 加索引:hotel_id

包含字段:ID, RoomTypeID(FK→RoomType), Date(time.Time), TotalCount, AvailableCount, Price(int64,分) 唯一约束:(room_type_id, date)

包含字段:ID(用UUID string), UserID(FK→User), HotelID(FK→Hotel), RoomTypeID(FK→RoomType), CheckInDate, CheckOutDate, GuestName, GuestPhone, Nights(int), TotalPrice(int64,分), Status(pending/confirmed/cancelled/completed), PaymentID, CancelledAt(*time.Time), CreatedAt, UpdatedAt 加索引:user_id, status, (user_id + status) 联合索引

创建函数 Run(db *gorm.DB):

  • 创建1个 admin 用户 ([email protected])
  • 创建2个 hotel_admin 用户
  • 创建10个酒店(分布在北京、上海、杭州、成都、深圳,每城市2个)
  • 每个酒店3种房型(标准间/大床房/套房,价格分别约200/350/600元)
  • 为每个房型生成未来30天的库存(每种房型每天10间)
  • 使用 FirstOrCreate 确保重复运行安全

所有价格用分(int64),例如 ¥200.00 = 20000

请输出所有文件的完整 Go 代码,可以直接复制使用。

### 步骤3: 后端 API
逐个实现 API 端点。
#### 3.1 Health Check

你是一名 Go+Gin 后端工程师。

请创建 server/handlers/health.go,实现:

GET /api/v1/health 响应:{“status”: “ok”, “time”: “2024-01-01T00:00:00Z”}

同时创建 server/routes.go,注册路由:

  • r := gin.Default()
  • v1 := r.Group(“/api/v1”)
  • v1.GET(“/health”, handlers.Health)

返回 r

请输出完整代码。

#### 3.2 搜索酒店 API

你是一名 Go+Gin+GORM 后端工程师。项目使用 PostgreSQL。

请创建 server/handlers/hotel.go,实现搜索酒店 API:

查询参数:

  • city (string, 必填) — 城市名
  • check_in (string, 必填) — 入住日期 YYYY-MM-DD
  • check_out (string, 必填) — 离店日期 YYYY-MM-DD
  • guests (int, 可选, 默认2) — 入住人数
  • min_price (int, 可选) — 最低价格(元,前端传元,后端转分)
  • max_price (int, 可选) — 最高价格(元)
  • page (int, 默认1)
  • page_size (int, 默认10, 最大50)

业务逻辑:

  1. 验证参数(city, check_in, check_out 必填,check_out > check_in)
  2. 查询 hotels 表,条件:city 匹配,status = ‘active’
  3. 对每个酒店,查询其房型中在指定日期范围内每天都有空房的最低价格
  4. 如果有 min_price/max_price,过滤价格
  5. 如果有 guests,过滤 max_guests >= guests 的房型
  6. 返回分页结果

响应格式:

{
"data": [
{
"id": 1,
"name": "北京希尔顿",
"city": "北京",
"address": "朝阳区xxx",
"rating": 4.5,
"image_url": "...",
"min_price": 20000,
"min_price_display": "200.00"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total": 100,
"total_pages": 10
}
}

注意:

  • 价格在数据库中以分(int64)存储
  • min_price_display 是给前端展示用的字符串,格式 “xxx.xx”
  • 搜索要考虑性能,用子查询而非 N+1

请输出完整的 Go 代码。包含参数验证、错误处理、分页逻辑。

#### 3.3 酒店详情 API

你是一名 Go+Gin+GORM 后端工程师。

请在 server/handlers/hotel.go 中添加酒店详情 API:

查询参数:

  • check_in (string, 可选) — 入住日期
  • check_out (string, 可选) — 离店日期

业务逻辑:

  1. 根据 ID 查询酒店基本信息
  2. 查询该酒店的所有房型
  3. 如果提供了 check_in 和 check_out,查询每种房型在该日期范围内的最低日价和可用房间数
  4. 可用房间数 = 日期范围内每天 available_count 的最小值

响应格式:

{
"data": {
"id": 1,
"name": "北京希尔顿",
"city": "北京",
"address": "朝阳区xxx",
"description": "...",
"rating": 4.5,
"image_url": "...",
"room_types": [
{
"id": 1,
"name": "标准间",
"description": "...",
"max_guests": 2,
"bed_type": "twin",
"area": 25,
"base_price": 20000,
"amenities": ["wifi", "tv"],
"image_url": "...",
"availability": {
"available": true,
"min_available_count": 8,
"price_per_night": 20000,
"price_per_night_display": "200.00"
}
}
]
}
}

如果没有提供日期,availability 字段为 null。 如果酒店不存在,返回 404。

请输出完整代码,在已有的 hotel.go 基础上追加。

#### 3.4 创建预订 API

你是一名 Go+Gin+GORM 后端工程师。

请创建 server/handlers/booking.go,实现创建预订 API:

请求头:Authorization: Bearer (本阶段先跳过认证,用请求体中的 user_id 代替)

请求体:

{
"user_id": 1,
"hotel_id": 1,
"room_type_id": 1,
"check_in": "2024-06-01",
"check_out": "2024-06-03",
"guest_name": "张三",
"guest_phone": "13800138000"
}

业务逻辑(在一个数据库事务中):

  1. 验证参数
  2. 检查房型是否属于该酒店
  3. 计算入住天数(check_out - check_in)
  4. 查询 inventory 表,获取日期范围内每天的价格和可用量
  5. 检查每一天的 available_count > 0(如果任何一天无房,返回 409 Conflict)
  6. 扣减库存:UPDATE inventory SET available_count = available_count - 1 WHERE room_type_id = ? AND date IN (?) AND available_count > 0
  7. 检查 affected rows = 天数(如果不等于,说明并发问题,回滚)
  8. 计算总价 = SUM(每天的 price)
  9. 创建订单(ID 用 UUID,状态 pending)
  10. 返回订单信息

响应格式:

{
"data": {
"id": "uuid-xxx",
"hotel_name": "北京希尔顿",
"room_type_name": "标准间",
"check_in": "2024-06-01",
"check_out": "2024-06-03",
"nights": 2,
"guest_name": "张三",
"total_price": 40000,
"total_price_display": "400.00",
"status": "pending",
"created_at": "2024-01-01T00:00:00Z"
}
}

错误情况:

  • 400: 参数不完整
  • 404: 酒店或房型不存在
  • 409: 所选日期无可用房间

请输出完整代码。注意事务的正确使用。

#### 3.5 取消预订 API

你是一名 Go+Gin+GORM 后端工程师。

请在 server/handlers/booking.go 中添加以下 API:

业务逻辑(在一个数据库事务中):

  1. 查询订单,检查状态是否可取消(只有 pending 和 confirmed 可取消)
  2. 更新订单状态为 cancelled,记录 cancelled_at
  3. 恢复库存:UPDATE inventory SET available_count = available_count + 1 WHERE room_type_id = ? AND date >= check_in AND date < check_out

查询参数:

  • user_id (int, 必填,本阶段暂时用参数传递)
  • status (string, 可选)
  • page, page_size

返回该用户的订单列表,按创建时间倒序,关联酒店名和房型名。

返回订单详情,关联酒店信息和房型信息。

请输出完整代码。记得在路由中注册这些新端点。同时更新 routes.go 添加所有预订相关路由。

### 步骤4: 前端页面
#### 4.1 API Client + 搜索页

你是一名 React + Vite + Tailwind CSS 前端工程师。

一个通用的 API 客户端:

  • 基础 URL 为空(Vite 代理会处理)
  • 封装 GET/POST/PUT/DELETE 方法
  • 自动添加 Content-Type: application/json
  • 自动添加 Authorization header(从 localStorage 读取 token)
  • 响应拦截:如果 401,清除 token 并跳转登录页
  • 导出 api 对象
// 使用示例
const res = await api.get('/api/v1/hotels/search?city=北京')
const data = await api.post('/api/v1/bookings', { ... })

酒店搜索页面:

  • 顶部搜索表单:城市(下拉选择:北京/上海/杭州/成都/深圳)、入住日期、离店日期、入住人数
  • 搜索按钮,点击后调用 GET /api/v1/hotels/search
  • 搜索结果:卡片列表,每张卡片显示酒店名称、城市、评分、最低价格
  • 点击卡片跳转到 /hotels/:id?check_in=xxx&check_out=xxx
  • 分页组件
  • 加载状态和空状态

样式要求:

  • 使用 Tailwind CSS
  • 响应式:手机端单列,桌面端两列卡片
  • 搜索表单居中,最大宽度 800px
  • 卡片有悬停效果

请输出完整的 JSX 代码。

#### 4.2 酒店详情页

你是一名 React + Tailwind CSS 前端工程师。

请创建 web/src/pages/HotelDetailPage.jsx

酒店详情页面,路由为 /hotels/:id

功能:

  1. 从 URL 参数获取酒店 ID,从 query string 获取 check_in 和 check_out
  2. 调用 GET /api/v1/hotels/:id?check_in=xxx&check_out=xxx
  3. 展示酒店基本信息(名称、地址、描述、评分)
  4. 展示房型列表,每种房型显示:
    • 名称、描述、面积、床型、最大入住人数
    • 设施标签(amenities)
    • 价格(每晚)
    • 可用房间数
    • “预订”按钮(可用时显示,不可用时置灰并显示”已满”)
  5. 点击”预订”按钮弹出预订表单弹窗:
    • 姓名、手机号输入框
    • 显示入住/离店日期、夜数、总价
    • 确认预订按钮 → 调用 POST /api/v1/bookings
    • 成功后跳转到订单详情页 /bookings/:id

样式要求:

  • Tailwind CSS
  • 酒店信息区块 + 房型列表区块
  • 弹窗用 fixed 定位 + 遮罩层
  • 房型卡片水平布局(左边信息,右边价格和按钮)

请输出完整 JSX 代码。临时用 localStorage 中的 user_id 模拟登录用户(之后会替换为 JWT)。

#### 4.3 订单页

你是一名 React + Tailwind CSS 前端工程师。

请创建以下两个页面:

我的订单列表页面,路由为 /bookings

功能:

  1. 调用 GET /api/v1/bookings?user_id=1(临时硬编码,之后替换)
  2. 展示订单列表,每条显示:
    • 酒店名称、房型名称
    • 入住/离店日期
    • 总价
    • 状态(用不同颜色标签:pending=黄色, confirmed=绿色, cancelled=灰色, completed=蓝色)
    • 操作按钮:查看详情、取消(仅 pending/confirmed 状态显示)
  3. 点击取消弹出确认对话框,确认后调用 POST /api/v1/bookings/:id/cancel
  4. 分页

订单详情页面,路由为 /bookings/:id

功能:

  1. 调用 GET /api/v1/bookings/:id
  2. 展示完整订单信息:
    • 订单号、状态
    • 酒店名称、房型名称
    • 入住人姓名、手机号
    • 入住/离店日期、夜数
    • 总价
    • 创建时间、取消时间(如果已取消)
  3. 取消按钮(仅 pending/confirmed 状态)

样式:Tailwind CSS,信息用卡片展示,状态用彩色标签。

请输出完整代码。

#### 4.4 路由配置和导航

你是一名 React 前端工程师,使用 React Router v7 + Tailwind CSS。

请创建/更新以下文件:

配置路由:

  • / → SearchPage
  • /hotels/:id → HotelDetailPage
  • /bookings → BookingsPage
  • /bookings/:id → BookingDetailPage

包含一个顶部导航栏组件。

导航栏:

  • 左侧:Logo “酒店预订” 点击回到首页
  • 右侧:
    • “我的订单” 链接到 /bookings
    • “登录” 按钮(暂时占位)
  • 样式:固定顶部,白色背景,底部阴影

通用分页组件:

  • Props: page, totalPages, onPageChange
  • 显示上一页、当前页码范围、下一页
  • 第一页时”上一页”禁用,最后一页时”下一页”禁用

请输出完整代码。确保 import 路径正确。

### 步骤5: 联调

我已经完成了酒店预订系统的前后端开发。现在需要联调。

  • 后端:Go + Gin,运行在 localhost:8080
  • 前端:React + Vite,运行在 localhost:5173
  • 数据库:PostgreSQL via Docker Compose

遇到的问题(请帮我逐一排查和解决)

Section titled “遇到的问题(请帮我逐一排查和解决)”

请确认 vite.config.js 的 proxy 配置是否正确。要求:

如果需要直接访问后端(不经过代理),Gin 需要 CORS 中间件。 请写出 server/middleware/cors.go:

  • 允许 localhost:5173
  • 允许 GET/POST/PUT/DELETE/OPTIONS
  • 允许 Authorization, Content-Type 头
  • 预检缓存 12 小时

前端传 “2024-06-01”,后端用 time.Time 解析。 请确认 JSON 绑定时的日期格式是否一致,必要时在 Go struct 中用自定义类型。

后端返回分(20000),前端需要展示为 “¥200.00”。 请写一个前端工具函数 formatPrice(cents)。

请给出一个按顺序的测试步骤列表,从启动服务到完成一次完整的搜索→详情→预订→查看订单流程。

请输出所有需要修改或创建的代码。

---
## 检查清单
- [ ] `docker-compose up -d` 能成功启动 PostgreSQL 和 Redis
- [ ] `go run .` 能成功启动后端,控制台无报错
- [ ] `npm run dev` 能成功启动前端
- [ ] GET /api/v1/health 返回 200
- [ ] 搜索页输入"北京"能返回酒店列表
- [ ] 点击酒店能跳转到详情页,显示房型和库存
- [ ] 预订操作成功后,库存减1,订单能在列表中看到
- [ ] 取消订单后,库存恢复,状态变为 cancelled
---
## 常见踩坑
1. **GORM 自动迁移不删列** — AutoMigrate 只会加列不会删列。如果你改了字段名(比如 `Price` 改成 `BasePrice`),旧列还在,新列会被添加。开发阶段可以手动 `DROP TABLE` 重建,但要记得重新 seed。
2. **Vite 代理只在开发模式生效** — `vite.config.js` 中的 proxy 只在 `npm run dev` 时有效。打包后(`npm run build`)需要 Nginx 或其他方式做反向代理。不要在前端代码中写死 `localhost:8080`。
3. **日期时区问题** — PostgreSQL 的 `date` 类型没有时区,但 Go 的 `time.Time` 默认带时区。搜索 "2024-06-01" 在不同时区可能匹配到不同日期。建议 Inventory 的 Date 字段用 `datatypes.Date` 或自定义类型,只存日期不存时间。
4. **N+1 查询** — 搜索酒店时,如果先查酒店列表,再逐个查每个酒店的最低价格,就是 N+1。应该用子查询或 JOIN 一次查出。GORM 的 `Preload` 也是额外查询(不是 JOIN),要注意。
5. **库存扣减的竞态条件** — MVP 阶段的 `available_count - 1` 在并发下会超卖。这里先用 `WHERE available_count > 0` 做基本防护,阶段3会用 `SELECT FOR UPDATE` 彻底解决。