阶段2: MVP实现
实现最小可用版本:用户能搜索酒店、查看详情、创建预订、取消预订。前后端都能跑起来,数据能从数据库到页面完整流通。
| 模块 | 核心知识点 | 在本阶段的作用 |
|---|---|---|
| Module 2 | 关系模型 / 索引 | GORM 模型定义、基础索引 |
| Module 1 | REST / 分页 | API 端点设计、分页实现 |
| Module 16 | Docker | 用 Docker Compose 跑 PostgreSQL + Redis |
| Module 13 | HTTP / CORS | Vite 代理、跨域配置 |
步骤1: 项目初始化
Section titled “步骤1: 项目初始化”创建项目目录结构、初始化 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.exampleDATABASE_URL=postgres://postgres:postgres@localhost:5432/hotel_reservation?sslmode=disableJWT_SECRET=dev-secret-change-meRUN_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
请输出所有文件的完整内容,可以直接复制使用。最后给出首次运行的命令顺序。步骤2: 数据库建表 + Seed 数据
Section titled “步骤2: 数据库建表 + Seed 数据”定义 GORM 模型并创建种子数据。
你是一名 Go 后端工程师,使用 GORM + PostgreSQL。
请创建以下文件:
## 1. server/models/user.go```gotype 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"`}2. server/models/hotel.go
Section titled “2. server/models/hotel.go”包含字段:ID, Name, Address, City, Province, Country, Latitude, Longitude, Description, Rating, ImageURL, OwnerID(FK→User), Status(active/inactive), CreatedAt, UpdatedAt 加索引:city, status, (city + status) 联合索引
3. server/models/room_type.go
Section titled “3. server/models/room_type.go”包含字段:ID, HotelID(FK→Hotel), Name, Description, MaxGuests, BedType, Area(平方米), BasePrice(int64,分), Amenities(JSON/datatypes.JSON), ImageURL, CreatedAt, UpdatedAt 加索引:hotel_id
4. server/models/inventory.go
Section titled “4. server/models/inventory.go”包含字段:ID, RoomTypeID(FK→RoomType), Date(time.Time), TotalCount, AvailableCount, Price(int64,分) 唯一约束:(room_type_id, date)
5. server/models/order.go
Section titled “5. server/models/order.go”包含字段: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) 联合索引
6. server/seed/seed.go
Section titled “6. server/seed/seed.go”创建函数 Run(db *gorm.DB):
- 创建1个 admin 用户 (admin@hotel.dev)
- 创建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:
GET /api/v1/hotels/search
Section titled “GET /api/v1/hotels/search”查询参数:
- 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)
业务逻辑:
- 验证参数(city, check_in, check_out 必填,check_out > check_in)
- 查询 hotels 表,条件:city 匹配,status = ‘active’
- 对每个酒店,查询其房型中在指定日期范围内每天都有空房的最低价格
- 如果有 min_price/max_price,过滤价格
- 如果有 guests,过滤 max_guests >= guests 的房型
- 返回分页结果
响应格式:
{ "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:
GET /api/v1/hotels/:id
Section titled “GET /api/v1/hotels/:id”查询参数:
- check_in (string, 可选) — 入住日期
- check_out (string, 可选) — 离店日期
业务逻辑:
- 根据 ID 查询酒店基本信息
- 查询该酒店的所有房型
- 如果提供了 check_in 和 check_out,查询每种房型在该日期范围内的最低日价和可用房间数
- 可用房间数 = 日期范围内每天 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:
POST /api/v1/bookings
Section titled “POST /api/v1/bookings”请求头:Authorization: Bearer
请求体:
{ "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"}业务逻辑(在一个数据库事务中):
- 验证参数
- 检查房型是否属于该酒店
- 计算入住天数(check_out - check_in)
- 查询 inventory 表,获取日期范围内每天的价格和可用量
- 检查每一天的 available_count > 0(如果任何一天无房,返回 409 Conflict)
- 扣减库存:UPDATE inventory SET available_count = available_count - 1 WHERE room_type_id = ? AND date IN (?) AND available_count > 0
- 检查 affected rows = 天数(如果不等于,说明并发问题,回滚)
- 计算总价 = SUM(每天的 price)
- 创建订单(ID 用 UUID,状态 pending)
- 返回订单信息
响应格式:
{ "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:
POST /api/v1/bookings/:id/cancel
Section titled “POST /api/v1/bookings/:id/cancel”业务逻辑(在一个数据库事务中):
- 查询订单,检查状态是否可取消(只有 pending 和 confirmed 可取消)
- 更新订单状态为 cancelled,记录 cancelled_at
- 恢复库存:UPDATE inventory SET available_count = available_count + 1 WHERE room_type_id = ? AND date >= check_in AND date < check_out
GET /api/v1/bookings
Section titled “GET /api/v1/bookings”查询参数:
- user_id (int, 必填,本阶段暂时用参数传递)
- status (string, 可选)
- page, page_size
返回该用户的订单列表,按创建时间倒序,关联酒店名和房型名。
GET /api/v1/bookings/:id
Section titled “GET /api/v1/bookings/:id”返回订单详情,关联酒店信息和房型信息。
请输出完整代码。记得在路由中注册这些新端点。同时更新 routes.go 添加所有预订相关路由。
### 步骤4: 前端页面
#### 4.1 API Client + 搜索页你是一名 React + Vite + Tailwind CSS 前端工程师。
1. 请创建 web/src/api/client.js
Section titled “1. 请创建 web/src/api/client.js”一个通用的 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', { ... })2. 请创建 web/src/pages/SearchPage.jsx
Section titled “2. 请创建 web/src/pages/SearchPage.jsx”酒店搜索页面:
- 顶部搜索表单:城市(下拉选择:北京/上海/杭州/成都/深圳)、入住日期、离店日期、入住人数
- 搜索按钮,点击后调用 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
功能:
- 从 URL 参数获取酒店 ID,从 query string 获取 check_in 和 check_out
- 调用 GET /api/v1/hotels/:id?check_in=xxx&check_out=xxx
- 展示酒店基本信息(名称、地址、描述、评分)
- 展示房型列表,每种房型显示:
- 名称、描述、面积、床型、最大入住人数
- 设施标签(amenities)
- 价格(每晚)
- 可用房间数
- “预订”按钮(可用时显示,不可用时置灰并显示”已满”)
- 点击”预订”按钮弹出预订表单弹窗:
- 姓名、手机号输入框
- 显示入住/离店日期、夜数、总价
- 确认预订按钮 → 调用 POST /api/v1/bookings
- 成功后跳转到订单详情页 /bookings/:id
样式要求:
- Tailwind CSS
- 酒店信息区块 + 房型列表区块
- 弹窗用 fixed 定位 + 遮罩层
- 房型卡片水平布局(左边信息,右边价格和按钮)
请输出完整 JSX 代码。临时用 localStorage 中的 user_id 模拟登录用户(之后会替换为 JWT)。
#### 4.3 订单页你是一名 React + Tailwind CSS 前端工程师。
请创建以下两个页面:
1. web/src/pages/BookingsPage.jsx
Section titled “1. web/src/pages/BookingsPage.jsx”我的订单列表页面,路由为 /bookings
功能:
- 调用 GET /api/v1/bookings?user_id=1(临时硬编码,之后替换)
- 展示订单列表,每条显示:
- 酒店名称、房型名称
- 入住/离店日期
- 总价
- 状态(用不同颜色标签:pending=黄色, confirmed=绿色, cancelled=灰色, completed=蓝色)
- 操作按钮:查看详情、取消(仅 pending/confirmed 状态显示)
- 点击取消弹出确认对话框,确认后调用 POST /api/v1/bookings/:id/cancel
- 分页
2. web/src/pages/BookingDetailPage.jsx
Section titled “2. web/src/pages/BookingDetailPage.jsx”订单详情页面,路由为 /bookings/:id
功能:
- 调用 GET /api/v1/bookings/:id
- 展示完整订单信息:
- 订单号、状态
- 酒店名称、房型名称
- 入住人姓名、手机号
- 入住/离店日期、夜数
- 总价
- 创建时间、取消时间(如果已取消)
- 取消按钮(仅 pending/confirmed 状态)
样式:Tailwind CSS,信息用卡片展示,状态用彩色标签。
请输出完整代码。
#### 4.4 路由配置和导航你是一名 React 前端工程师,使用 React Router v7 + Tailwind CSS。
请创建/更新以下文件:
1. web/src/App.jsx
Section titled “1. web/src/App.jsx”配置路由:
- / → SearchPage
- /hotels/:id → HotelDetailPage
- /bookings → BookingsPage
- /bookings/:id → BookingDetailPage
包含一个顶部导航栏组件。
2. web/src/components/Navbar.jsx
Section titled “2. web/src/components/Navbar.jsx”导航栏:
- 左侧:Logo “酒店预订” 点击回到首页
- 右侧:
- “我的订单” 链接到 /bookings
- “登录” 按钮(暂时占位)
- 样式:固定顶部,白色背景,底部阴影
3. web/src/components/Pagination.jsx
Section titled “3. web/src/components/Pagination.jsx”通用分页组件:
- Props: page, totalPages, onPageChange
- 显示上一页、当前页码范围、下一页
- 第一页时”上一页”禁用,最后一页时”下一页”禁用
请输出完整代码。确保 import 路径正确。
### 步骤5: 联调我已经完成了酒店预订系统的前后端开发。现在需要联调。
- 后端:Go + Gin,运行在 localhost:8080
- 前端:React + Vite,运行在 localhost:5173
- 数据库:PostgreSQL via Docker Compose
遇到的问题(请帮我逐一排查和解决)
Section titled “遇到的问题(请帮我逐一排查和解决)”1. Vite 代理配置
Section titled “1. Vite 代理配置”请确认 vite.config.js 的 proxy 配置是否正确。要求:
- /api 开头的请求代理到 http://localhost:8080
- 不改变 origin
2. CORS 配置
Section titled “2. CORS 配置”如果需要直接访问后端(不经过代理),Gin 需要 CORS 中间件。 请写出 server/middleware/cors.go:
- 允许 localhost:5173
- 允许 GET/POST/PUT/DELETE/OPTIONS
- 允许 Authorization, Content-Type 头
- 预检缓存 12 小时
3. 日期格式
Section titled “3. 日期格式”前端传 “2024-06-01”,后端用 time.Time 解析。 请确认 JSON 绑定时的日期格式是否一致,必要时在 Go struct 中用自定义类型。
4. 价格展示
Section titled “4. 价格展示”后端返回分(20000),前端需要展示为 “¥200.00”。 请写一个前端工具函数 formatPrice(cents)。
5. 联调检查清单
Section titled “5. 联调检查清单”请给出一个按顺序的测试步骤列表,从启动服务到完成一次完整的搜索→详情→预订→查看订单流程。
请输出所有需要修改或创建的代码。
---
## 检查清单
- [ ] `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` 彻底解决。