This commit is contained in:
lingxiao865
2026-02-10 09:30:37 +08:00
commit 13d1175057
15 changed files with 1728 additions and 0 deletions

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Stage 1: Build
FROM golang:1.25.5-alpine AS builder
RUN apk add --no-cache tzdata
WORKDIR /app
ENV GOPROXY=https://goproxy.cn,direct
ENV GOSUMDB=off
# 1. 先拷贝并下载依赖(利用 Docker 层缓存)
COPY go.mod go.sum ./
RUN go mod download
# 2. 拷贝源代码和环境配置文件
COPY . .
# 3. 正确使用 BuildKit 缓存挂载(必须将 --mount 和 go build 放在同一行 RUN 中)
# 这样即便代码变了,/root/.cache/go-build 下的增量编译结果依然会被复用
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Run
FROM alpine:latest AS runner
# 安装必要的依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为东八区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# 复制二进制文件和环境配置文件
COPY --from=builder /app/main .
COPY --from=builder /app/.env .env
# 设置环境变量默认值
ENV SERVER_PORT=6555
EXPOSE 6555
CMD ["./main"]

394
README.md Normal file
View File

@@ -0,0 +1,394 @@
# 预约管理系统 (Appointment Management System)
基于 Go 语言开发的现代化预约管理系统,采用 Fiber 框架和 MySQL 数据库,支持用户预约、时间管理、权限控制等功能。
## 🚀 项目特性
- **RESTful API 设计** - 清晰的接口规范,易于集成
- **JWT 身份认证** - 安全的用户身份验证机制
- **多角色权限控制** - 用户和管理员分级权限管理
- **预约时间管理** - 灵活的时间槽配置和预约控制
- **Docker 容器化部署** - 支持容器化部署和快速启动
- **数据库迁移** - 自动化的数据库结构管理
## 🏗️ 技术架构
### 核心技术栈
- **后端框架**: [Fiber v2](https://gofiber.io/) - 高性能 Go Web 框架
- **数据库**: MySQL 8.0+
- **ORM**: [GORM](https://gorm.io/) - 强大的 Go ORM 库
- **认证**: JWT (JSON Web Tokens)
- **部署**: Docker + Docker Compose
### 项目结构
```
yuyue/
├── config/ # 配置管理
│ └── config.go # 环境配置加载
├── database/ # 数据库连接
│ └── database.go # 数据库初始化
├── handlers/ # API 处理器
│ ├── auth.go # 认证相关接口
│ ├── appointment.go # 预约管理接口
│ └── timeslot.go # 时间槽管理接口
├── middleware/ # 中间件
│ ├── auth.go # 认证中间件
│ └── jwt.go # JWT 工具函数
├── models/ # 数据模型
│ └── models.go # 数据库模型定义
├── utils/ # 工具函数
│ ├── response.go # 统一响应格式
│ └── timeslot_generator.go # 时间槽生成工具
├── Dockerfile # Docker 构建文件
├── go.mod # Go 模块依赖
└── main.go # 应用入口
```
## 🔧 环境要求
- Go 1.25+
- MySQL 8.0+
- Docker (可选,用于容器化部署)
## 📦 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd yuyue
```
### 2. 配置环境变量
复制 `.env.example``.env` 并修改配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=appointment_db
# JWT 密钥 (生产环境请修改)
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# 服务器端口
SERVER_PORT=6555
```
### 3. 数据库准备
创建数据库:
```sql
CREATE DATABASE appointment_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
### 4. 运行应用
#### 方式一:直接运行
```bash
# 安装依赖
go mod tidy
# 运行应用
go run main.go
```
#### 方式二:编译运行
```bash
# 编译
go build -o appointment.exe .
# 运行
./appointment.exe
```
#### 方式三Docker 运行
```bash
# 构建镜像
docker build -t appointment-system .
# 运行容器
docker run -p 6555:6555 appointment-system
```
## 📚 API 接口文档
### 认证相关接口
#### 用户注册
```http
POST /api/auth/register
Content-Type: application/json
{
"phone": "+8613800138000",
"nickname": "张三"
}
```
#### 发送验证码
```http
POST /api/auth/send-code
Content-Type: application/json
{
"phone": "+8613800138000"
}
```
#### 验证码登录
```http
POST /api/auth/verification-login
Content-Type: application/json
{
"phone": "+8613800138000",
"code": "123456"
}
```
#### 一键登录
```http
POST /api/auth/one-click-login
Content-Type: application/json
{
"phone": "+8613800138000"
}
```
### 用户管理接口
#### 获取用户信息
```http
GET /api/users/profile
Authorization: Bearer <token>
```
#### 更新用户信息
```http
PUT /api/users/profile
Authorization: Bearer <token>
Content-Type: application/json
{
"nickname": "新昵称"
}
```
### 时间槽管理接口
#### 获取时间槽列表
```http
GET /api/timeslots
# 可选参数: ?date=2026-02-10
```
#### 获取单个时间槽
```http
GET /api/timeslots/{id}
```
#### 创建时间槽 (管理员)
```http
POST /api/admin/timeslots
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"date": "2026-02-15",
"start_time": "09:00",
"end_time": "10:00",
"max_people": 5,
"is_active": true
}
```
### 预约管理接口
#### 创建预约
```http
POST /api/appointments
Authorization: Bearer <token>
Content-Type: application/json
{
"time_slot_id": 1,
"people_count": 2,
"notes": "预约备注"
}
```
#### 获取预约列表
```http
GET /api/appointments
Authorization: Bearer <token>
# 可选参数: ?status=pending&date=2026-02-10
```
#### 获取单个预约
```http
GET /api/appointments/{id}
Authorization: Bearer <token>
```
#### 取消预约
```http
DELETE /api/appointments/{id}
Authorization: Bearer <token>
```
#### 更新预约状态 (管理员)
```http
PUT /api/admin/appointments/{id}/status
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"status": "confirmed" // pending, confirmed, cancelled, completed
}
```
## 🔐 权限说明
系统包含两种用户角色:
- **普通用户 (user)**: 可以查看时间槽、创建和管理自己的预约
- **管理员 (admin)**: 拥有所有权限,包括管理时间槽和审核预约
## 🛠️ 开发指南
### 项目构建
```bash
# 构建可执行文件
go build -o appointment.exe .
# 运行测试
go test -v ./...
```
### 代码规范
- 使用 `gofmt` 格式化代码
- 遵循 RESTful API 设计原则
- 统一的错误处理和响应格式
### 数据库迁移
项目使用 GORM 的 AutoMigrate 功能自动创建和更新数据库表结构。
## 🐳 Docker 部署
### 构建镜像
```bash
docker build -t appointment-system .
```
### 运行容器
```bash
docker run -d \
--name appointment-app \
-p 6555:6555 \
-e DB_HOST=your_db_host \
-e DB_PASSWORD=your_db_password \
appointment-system
```
### Docker Compose (推荐)
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "6555:6555"
environment:
- DB_HOST=db
- DB_PASSWORD=your_password
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: your_password
MYSQL_DATABASE: appointment_db
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
```
## 📊 数据模型
### 用户表 (users)
```go
type User struct {
ID uint `gorm:"primaryKey"`
Phone string `gorm:"size:20;uniqueIndex"`
Nickname string `gorm:"size:100"`
Role string `gorm:"size:20;default:user"` // user, admin
CreatedAt time.Time
UpdatedAt time.Time
}
```
### 时间槽表 (time_slots)
```go
type TimeSlot struct {
ID uint `gorm:"primaryKey"`
Date time.Time `gorm:"not null;index"`
StartTime time.Time `gorm:"not null"`
EndTime time.Time `gorm:"not null"`
MaxPeople int `gorm:"not null;default:1"`
CurrentPeople int `gorm:"not null;default:0"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
### 预约表 (appointments)
```go
type Appointment struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null"`
TimeSlotID uint `gorm:"not null"`
PeopleCount int `gorm:"not null;default:1"`
Status string `gorm:"size:20;default:pending"` // pending, confirmed, cancelled, completed
Notes string `gorm:"size:500"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
## 🤝 贡献指南
欢迎提交 Issue 和 Pull Request
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 📝 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 📞 联系方式
如有问题或建议,请通过以下方式联系:
- 提交 Issue
- 发送邮件至: [your-email@example.com]
## 🙏 致谢
感谢以下开源项目的支持:
- [Fiber](https://gofiber.io/)
- [GORM](https://gorm.io/)
- [JWT](https://github.com/golang-jwt/jwt)

55
config/config.go Normal file
View File

@@ -0,0 +1,55 @@
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
JWTSecret string
ServerPort string
}
func LoadConfig() *Config {
// 加载 .env 文件
err := godotenv.Load()
if err != nil {
log.Println("警告: 未找到 .env 文件,使用环境变量")
}
config := &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "root"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "appointment_db"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
ServerPort: getEnv("SERVER_PORT", "3000"),
}
return config
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvAsInt(key string, defaultValue int) int {
if valueStr := os.Getenv(key); valueStr != "" {
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
}
return defaultValue
}

51
database/database.go Normal file
View File

@@ -0,0 +1,51 @@
package database
import (
"fmt"
"log"
"yuyue/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
func ConnectDatabase(cfg *config.Config) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Asia%%2FShanghai",
cfg.DBUser,
cfg.DBPassword,
cfg.DBHost,
cfg.DBPort,
cfg.DBName,
)
var err error
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
log.Println("Database connection established!")
//// 自动迁移数据库表
//err = DB.AutoMigrate(
// &models.User{},
// &models.TimeSlot{},
// &models.Appointment{},
//)
//if err != nil {
// log.Fatal("Failed to migrate database:", err)
//}
log.Println("Database migration completed!")
}
func GetDB() *gorm.DB {
return DB
}

37
go.mod Normal file
View File

@@ -0,0 +1,37 @@
module yuyue
go 1.25
require (
github.com/gofiber/fiber/v2 v2.52.11
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.47.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

87
go.sum Normal file
View File

@@ -0,0 +1,87 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

240
handlers/appointment.go Normal file
View File

@@ -0,0 +1,240 @@
package handlers
import (
"strconv"
"time"
"yuyue/database"
"yuyue/middleware"
"yuyue/models"
"yuyue/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type AppointmentRequest struct {
TimeSlotID uint `json:"time_slot_id" validate:"required"`
PeopleCount int `json:"people_count" validate:"required,min=1"`
Notes string `json:"notes"`
}
type AppointmentQuery struct {
Status string `query:"status"`
Date string `query:"date"`
}
func CreateAppointment(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
var req AppointmentRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// 获取时间槽信息
var timeSlot models.TimeSlot
if err := database.GetDB().First(&timeSlot, req.TimeSlotID).Error; err != nil {
return utils.NotFound(c, "时间槽不存在")
}
// 检查时间槽是否激活
if !timeSlot.IsActive {
return utils.BadRequest(c, "该时间段暂不开放预约")
}
// 检查时间槽是否已过期
now := time.Now()
if timeSlot.Date.Before(now.Truncate(24 * time.Hour)) {
return utils.BadRequest(c, "不能预约过去的时间")
}
// 检查人数限制
if req.PeopleCount > timeSlot.MaxPeople {
return utils.BadRequest(c, "预约人数超过该时间段的最大限制")
}
// 检查剩余容量
if timeSlot.CurrentPeople+req.PeopleCount > timeSlot.MaxPeople {
remaining := timeSlot.MaxPeople - timeSlot.CurrentPeople
return utils.BadRequest(c,
"该时间段剩余容量不足,最多还可预约"+strconv.Itoa(remaining)+"人")
}
// 检查用户是否已在此时间段预约
var existingAppointment models.Appointment
err := database.GetDB().Where("user_id = ? AND time_slot_id = ? AND status != ?",
userID, req.TimeSlotID, models.AppointmentCancelled).First(&existingAppointment).Error
if err == nil {
return utils.BadRequest(c, "您已在此时间段有预约")
}
appointment := models.Appointment{
UserID: userID,
TimeSlotID: req.TimeSlotID,
PeopleCount: req.PeopleCount,
Status: models.AppointmentPending,
Notes: req.Notes,
}
if err := database.GetDB().Create(&appointment).Error; err != nil {
return utils.InternalServerError(c, "创建预约失败")
}
// 更新时间槽的当前人数
timeSlot.CurrentPeople += req.PeopleCount
if err := database.GetDB().Save(&timeSlot).Error; err != nil {
// 如果更新失败,回滚预约
database.GetDB().Delete(&appointment)
return utils.InternalServerError(c, "更新时间槽人数失败")
}
// 预加载关联数据
database.GetDB().Preload("User").Preload("TimeSlot").First(&appointment, appointment.ID)
return utils.Success(c, appointment)
}
func GetAppointments(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
role := middleware.GetUserRole(c)
var appointments []models.Appointment
query := database.GetDB().Model(&models.Appointment{})
// 管理员可以看到所有预约,普通用户只能看到自己的预约
if role != models.RoleAdmin {
query = query.Where("user_id = ?", userID)
}
// 可选的查询参数
status := c.Query("status")
if status != "" {
query = query.Where("status = ?", status)
}
date := c.Query("date")
if date != "" {
query = query.Joins("JOIN time_slots ON appointments.time_slot_id = time_slots.id").
Where("time_slots.date = ?", date)
}
// 按创建时间降序排列
query = query.Order("created_at DESC")
if err := query.Preload("User").Preload("TimeSlot").Find(&appointments).Error; err != nil {
return utils.InternalServerError(c, "获取预约列表失败")
}
return utils.Success(c, appointments)
}
func GetAppointment(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
role := middleware.GetUserRole(c)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的预约ID")
}
var appointment models.Appointment
query := database.GetDB().Preload("User").Preload("TimeSlot")
// 管理员可以查看所有预约,普通用户只能查看自己的预约
if role != models.RoleAdmin {
query = query.Where("id = ? AND user_id = ?", uint(id), userID)
} else {
query = query.Where("id = ?", uint(id))
}
if err := query.First(&appointment).Error; err != nil {
return utils.NotFound(c, "预约不存在")
}
return utils.Success(c, appointment)
}
func UpdateAppointmentStatus(c *fiber.Ctx) error {
// 检查是否为管理员
if middleware.GetUserRole(c) != models.RoleAdmin {
return utils.Unauthorized(c, "需要管理员权限")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的预约ID")
}
type StatusUpdate struct {
Status string `json:"status" validate:"required,oneof=pending confirmed cancelled completed"`
}
var req StatusUpdate
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
var appointment models.Appointment
if err := database.GetDB().Preload("TimeSlot").First(&appointment, uint(id)).Error; err != nil {
return utils.NotFound(c, "预约不存在")
}
oldStatus := appointment.Status
appointment.Status = req.Status
if err := database.GetDB().Save(&appointment).Error; err != nil {
return utils.InternalServerError(c, "更新预约状态失败")
}
// 如果是从已确认状态变为取消状态,需要释放人数
if oldStatus == models.AppointmentConfirmed && req.Status == models.AppointmentCancelled {
timeSlot := appointment.TimeSlot
timeSlot.CurrentPeople -= appointment.PeopleCount
database.GetDB().Save(&timeSlot)
}
// 预加载关联数据
database.GetDB().Preload("User").Preload("TimeSlot").First(&appointment, appointment.ID)
return utils.Success(c, appointment)
}
func CancelAppointment(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的预约ID")
}
var appointment models.Appointment
if err := database.GetDB().Preload("TimeSlot").Where("id = ? AND user_id = ?", uint(id), userID).First(&appointment).Error; err != nil {
return utils.NotFound(c, "预约不存在或无权取消")
}
// 检查预约状态
if appointment.Status == models.AppointmentCancelled || appointment.Status == models.AppointmentCompleted {
return utils.BadRequest(c, "无法取消已完成或已取消的预约")
}
appointment.Status = models.AppointmentCancelled
// 释放时间槽的人数
timeSlot := appointment.TimeSlot
timeSlot.CurrentPeople -= appointment.PeopleCount
if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&appointment).Error; err != nil {
return err
}
if err := tx.Save(&timeSlot).Error; err != nil {
return err
}
return nil
}); err != nil {
return utils.InternalServerError(c, "取消预约失败")
}
return utils.Success(c, appointment)
}

210
handlers/auth.go Normal file
View File

@@ -0,0 +1,210 @@
package handlers
import (
"yuyue/database"
"yuyue/middleware"
"yuyue/models"
"yuyue/utils"
"github.com/gofiber/fiber/v2"
)
type UserRequest struct {
Phone string `json:"phone" validate:"required,e164"`
Nickname string `json:"nickname" validate:"required,min=1,max=50"`
}
type LoginRequest struct {
Phone string `json:"phone" validate:"required,e164"`
Password string `json:"password" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.User `json:"user"`
}
func Register(c *fiber.Ctx) error {
var req UserRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// 检查手机号是否已存在
var existingUser models.User
if err := database.GetDB().Where("phone = ?", req.Phone).First(&existingUser).Error; err == nil {
return utils.BadRequest(c, "该手机号已注册")
}
// 创建新用户
user := models.User{
Phone: req.Phone,
Nickname: req.Nickname,
Role: models.RoleUser,
}
if err := database.GetDB().Create(&user).Error; err != nil {
return utils.InternalServerError(c, "用户创建失败")
}
// 生成JWT令牌
token, err := middleware.GenerateToken(&user)
if err != nil {
return utils.InternalServerError(c, "令牌生成失败")
}
return utils.Success(c, AuthResponse{
Token: token,
User: user,
})
}
func GetProfile(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
var user models.User
if err := database.GetDB().First(&user, userID).Error; err != nil {
return utils.NotFound(c, "用户不存在")
}
return utils.Success(c, user)
}
type VerificationLoginRequest struct {
Phone string `json:"phone" validate:"required,e164"`
Code string `json:"code" validate:"required,len=6"`
}
type OneClickLoginRequest struct {
Phone string `json:"phone" validate:"required,e164"`
}
// 验证码登录
func VerificationLogin(c *fiber.Ctx) error {
var req VerificationLoginRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// TODO: 验证验证码是否正确
// 这里应该从缓存或数据库中验证验证码
// if !verifyCode(req.Phone, req.Code) {
// return utils.BadRequest(c, "验证码错误或已过期")
// }
// 检查用户是否存在,不存在则自动创建
var user models.User
err := database.GetDB().Where("phone = ?", req.Phone).First(&user).Error
if err != nil {
// 用户不存在,自动创建
user = models.User{
Phone: req.Phone,
Nickname: "用户" + req.Phone[len(req.Phone)-4:], // 默认昵称为手机号后四位
Role: models.RoleUser,
}
if err := database.GetDB().Create(&user).Error; err != nil {
return utils.InternalServerError(c, "用户创建失败")
}
}
// 生成JWT令牌
token, err := middleware.GenerateToken(&user)
if err != nil {
return utils.InternalServerError(c, "令牌生成失败")
}
return utils.Success(c, AuthResponse{
Token: token,
User: user,
})
}
// 一键登录(免验证码,直接登录)
func OneClickLogin(c *fiber.Ctx) error {
var req OneClickLoginRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// 检查用户是否存在,不存在则自动创建
var user models.User
err := database.GetDB().Where("phone = ?", req.Phone).First(&user).Error
if err != nil {
// 用户不存在,自动创建
user = models.User{
Phone: req.Phone,
Nickname: "用户" + req.Phone[len(req.Phone)-4:], // 默认昵称为手机号后四位
Role: models.RoleUser,
}
if err := database.GetDB().Create(&user).Error; err != nil {
return utils.InternalServerError(c, "用户创建失败")
}
}
// 生成JWT令牌
token, err := middleware.GenerateToken(&user)
if err != nil {
return utils.InternalServerError(c, "令牌生成失败")
}
return utils.Success(c, AuthResponse{
Token: token,
User: user,
})
}
// 发送验证码
func SendVerificationCode(c *fiber.Ctx) error {
type SendCodeRequest struct {
Phone string `json:"phone" validate:"required,e164"`
}
var req SendCodeRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// TODO: 实际发送验证码逻辑
// 这里应该调用短信服务发送验证码
// 并将验证码存储到缓存中,设置过期时间
// sendSMS(req.Phone, code)
// cache.Set(req.Phone, code, 5*time.Minute)
// 模拟生成6位验证码
// code := "123456" // 实际应该随机生成
// 返回成功响应(实际开发中不应返回验证码)
return utils.Success(c, fiber.Map{
"message": "验证码已发送,请注意查收",
"expires_in": 300, // 5分钟过期
})
}
func UpdateProfile(c *fiber.Ctx) error {
userID := middleware.GetUserID(c)
type UpdateProfileRequest struct {
Nickname string `json:"nickname" validate:"min=1,max=50"`
}
var req UpdateProfileRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
var user models.User
if err := database.GetDB().First(&user, userID).Error; err != nil {
return utils.NotFound(c, "用户不存在")
}
// 更新昵称
if req.Nickname != "" {
user.Nickname = req.Nickname
}
if err := database.GetDB().Save(&user).Error; err != nil {
return utils.InternalServerError(c, "更新用户信息失败")
}
return utils.Success(c, user)
}

218
handlers/timeslot.go Normal file
View File

@@ -0,0 +1,218 @@
package handlers
import (
"strconv"
"time"
"yuyue/database"
"yuyue/middleware"
"yuyue/models"
"yuyue/utils"
"github.com/gofiber/fiber/v2"
)
type TimeSlotRequest struct {
Date string `json:"date" validate:"required"`
StartTime string `json:"start_time" validate:"required"`
EndTime string `json:"end_time" validate:"required"`
MaxPeople int `json:"max_people" validate:"required,min=1"`
IsActive bool `json:"is_active"`
}
func CreateTimeSlot(c *fiber.Ctx) error {
// 检查是否为管理员
if middleware.GetUserRole(c) != models.RoleAdmin {
return utils.Unauthorized(c, "需要管理员权限")
}
var req TimeSlotRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
// 解析日期时间
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return utils.BadRequest(c, "日期格式错误,应为 YYYY-MM-DD")
}
startTime, err := time.Parse("15:04", req.StartTime)
if err != nil {
return utils.BadRequest(c, "开始时间格式错误,应为 HH:MM")
}
endTime, err := time.Parse("15:04", req.EndTime)
if err != nil {
return utils.BadRequest(c, "结束时间格式错误,应为 HH:MM")
}
// 组合完整的时间
fullStartTime := time.Date(date.Year(), date.Month(), date.Day(),
startTime.Hour(), startTime.Minute(), 0, 0, date.Location())
fullEndTime := time.Date(date.Year(), date.Month(), date.Day(),
endTime.Hour(), endTime.Minute(), 0, 0, date.Location())
// 检查时间合理性
if fullStartTime.After(fullEndTime) || fullStartTime.Equal(fullEndTime) {
return utils.BadRequest(c, "开始时间必须早于结束时间")
}
timeSlot := models.TimeSlot{
Date: date,
StartTime: fullStartTime,
EndTime: fullEndTime,
MaxPeople: req.MaxPeople,
CurrentPeople: 0,
IsActive: req.IsActive,
}
if err := database.GetDB().Create(&timeSlot).Error; err != nil {
return utils.InternalServerError(c, "时间槽创建失败")
}
return utils.Success(c, timeSlot)
}
func GetTimeSlots(c *fiber.Ctx) error {
var timeSlots []models.TimeSlot
query := database.GetDB().Model(&models.TimeSlot{})
// 可选的查询参数
date := c.Query("date")
if date != "" {
query = query.Where("date = ? ", date)
} else {
// 2. 如果没传日期,才默认显示“今天及以后”的数据
today := time.Now()
sevenDaysLater := today.AddDate(0, 0, 14)
query = query.Where("date >= ? and date <= ?", today.Format("2006-01-02"), sevenDaysLater.Format("2006-01-02"))
}
//isActive := c.Query("is_active")
//if isActive != "" {
// active, err := strconv.ParseBool(isActive)
// if err == nil {
// query = query.Where("is_active = ?", active)
// }
//}
////
//// 只显示未来的时段
now := time.Now().Format("2006-01-02")
query = query.Where("date >= ? and is_active = true", now)
// 按日期和时间排序
query = query.Order("date ASC, start_time ASC")
if err := query.Find(&timeSlots).Error; err != nil {
return utils.InternalServerError(c, "获取时间槽列表失败")
}
return utils.Success(c, timeSlots)
}
func GetTimeSlot(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的时间槽ID")
}
var timeSlot models.TimeSlot
if err := database.GetDB().First(&timeSlot, uint(id)).Error; err != nil {
return utils.NotFound(c, "时间槽不存在")
}
return utils.Success(c, timeSlot)
}
func UpdateTimeSlot(c *fiber.Ctx) error {
// 检查是否为管理员
if middleware.GetUserRole(c) != models.RoleAdmin {
return utils.Unauthorized(c, "需要管理员权限")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的时间槽ID")
}
var req TimeSlotRequest
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "请求数据格式错误")
}
var timeSlot models.TimeSlot
if err := database.GetDB().First(&timeSlot, uint(id)).Error; err != nil {
return utils.NotFound(c, "时间槽不存在")
}
// 解析日期时间
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
return utils.BadRequest(c, "日期格式错误,应为 YYYY-MM-DD")
}
startTime, err := time.Parse("15:04", req.StartTime)
if err != nil {
return utils.BadRequest(c, "开始时间格式错误,应为 HH:MM")
}
endTime, err := time.Parse("15:04", req.EndTime)
if err != nil {
return utils.BadRequest(c, "结束时间格式错误,应为 HH:MM")
}
// 组合完整的时间
fullStartTime := time.Date(date.Year(), date.Month(), date.Day(),
startTime.Hour(), startTime.Minute(), 0, 0, date.Location())
fullEndTime := time.Date(date.Year(), date.Month(), date.Day(),
endTime.Hour(), endTime.Minute(), 0, 0, date.Location())
// 检查时间合理性
if fullStartTime.After(fullEndTime) || fullStartTime.Equal(fullEndTime) {
return utils.BadRequest(c, "开始时间必须早于结束时间")
}
// 检查是否会影响现有预约
if req.MaxPeople < timeSlot.CurrentPeople {
return utils.BadRequest(c, "最大人数不能小于当前已预约人数")
}
timeSlot.Date = date
timeSlot.StartTime = fullStartTime
timeSlot.EndTime = fullEndTime
timeSlot.MaxPeople = req.MaxPeople
timeSlot.IsActive = req.IsActive
if err := database.GetDB().Save(&timeSlot).Error; err != nil {
return utils.InternalServerError(c, "更新时间槽失败")
}
return utils.Success(c, timeSlot)
}
func DeleteTimeSlot(c *fiber.Ctx) error {
// 检查是否为管理员
if middleware.GetUserRole(c) != models.RoleAdmin {
return utils.Unauthorized(c, "需要管理员权限")
}
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return utils.BadRequest(c, "无效的时间槽ID")
}
// 检查是否有关联的预约
var appointmentCount int64
database.GetDB().Model(&models.Appointment{}).Where("time_slot_id = ?", uint(id)).Count(&appointmentCount)
if appointmentCount > 0 {
return utils.BadRequest(c, "该时间槽已有预约,无法删除")
}
if err := database.GetDB().Delete(&models.TimeSlot{}, uint(id)).Error; err != nil {
return utils.InternalServerError(c, "删除时间槽失败")
}
return utils.Success(c, fiber.Map{"message": "时间槽删除成功"})
}

100
main.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"log"
"yuyue/config"
"yuyue/database"
"yuyue/handlers"
"yuyue/middleware"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
func main() {
// 加载配置
cfg := config.LoadConfig()
// 连接数据库
database.ConnectDatabase(cfg)
//// 初始化两个月的预约时间槽
//if err := utils.GenerateTwoMonthsTimeSlots(); err != nil {
// log.Printf("生成时间槽失败: %v", err)
//} else {
// log.Println("✅ 成功生成最近两个月的预约时间槽")
//}
// 创建 Fiber 应用
app := fiber.New(fiber.Config{
AppName: "预约系统",
})
// 中间件
app.Use(logger.New())
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
}))
// 设置路由
setupRoutes(app)
// 启动服务器
log.Printf("🚀 服务器启动在端口 %s", cfg.ServerPort)
log.Fatal(app.Listen(":" + cfg.ServerPort))
}
func setupRoutes(app *fiber.App) {
// 公开路由
auth := app.Group("/api/auth")
auth.Post("/register", handlers.Register)
auth.Post("/send-code", handlers.SendVerificationCode) // 发送验证码
auth.Post("/verification-login", handlers.VerificationLogin) // 验证码登录
auth.Post("/one-click-login", handlers.OneClickLogin) // 一键登录
// 时间槽相关路由(公开)
timeslots := app.Group("/api/timeslots")
timeslots.Get("/", handlers.GetTimeSlots)
timeslots.Get("/:id", handlers.GetTimeSlot)
// 需要认证的路由
protected := app.Group("/api")
protected.Use(middleware.AuthMiddleware())
// 用户相关路由
users := protected.Group("/users")
users.Get("/profile", handlers.GetProfile)
users.Put("/profile", handlers.UpdateProfile)
// 预约相关路由
appointments := protected.Group("/appointments")
appointments.Post("/", handlers.CreateAppointment)
appointments.Get("/", handlers.GetAppointments)
appointments.Get("/:id", handlers.GetAppointment)
appointments.Delete("/:id", handlers.CancelAppointment)
// 管理员路由
admin := protected.Group("/admin")
admin.Use(middleware.AdminMiddleware())
// 管理时间槽
adminTimeSlots := admin.Group("/timeslots")
adminTimeSlots.Post("/", handlers.CreateTimeSlot)
adminTimeSlots.Put("/:id", handlers.UpdateTimeSlot)
adminTimeSlots.Delete("/:id", handlers.DeleteTimeSlot)
// 管理预约
adminAppointments := admin.Group("/appointments")
adminAppointments.Put("/:id/status", handlers.UpdateAppointmentStatus)
// 健康检查
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"message": "预约系统运行正常",
})
})
}

68
middleware/auth.go Normal file
View File

@@ -0,0 +1,68 @@
package middleware
import (
"strings"
"yuyue/utils"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
// jwtSecret 在 jwt.go 中定义
func AuthMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return utils.Unauthorized(c, "缺少认证信息")
}
tokenString := strings.Replace(authHeader, "Bearer ", "", 1)
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
return utils.Unauthorized(c, "无效的认证令牌")
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
c.Locals("user_id", claims["user_id"])
c.Locals("username", claims["username"])
c.Locals("role", claims["role"])
}
return c.Next()
}
}
func AdminMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
role := c.Locals("role")
if role != "admin" {
return utils.Unauthorized(c, "需要管理员权限")
}
return c.Next()
}
}
func GetUserID(c *fiber.Ctx) uint {
userID := c.Locals("user_id")
if userID != nil {
if id, ok := userID.(float64); ok {
return uint(id)
}
}
return 0
}
func GetUserRole(c *fiber.Ctx) string {
role := c.Locals("role")
if role != nil {
if r, ok := role.(string); ok {
return r
}
}
return ""
}

39
middleware/jwt.go Normal file
View File

@@ -0,0 +1,39 @@
package middleware
import (
"time"
"yuyue/models"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var jwtSecret = []byte("your-secret-key")
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(user *models.User) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}

61
models/models.go Normal file
View File

@@ -0,0 +1,61 @@
package models
import (
"time"
"gorm.io/gorm"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Phone string `gorm:"size:20;uniqueIndex;not null" json:"phone"`
Nickname string `gorm:"size:100" json:"nickname"`
Role string `gorm:"size:20;default:user" json:"role"` // user, admin
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TimeSlot 时间槽模型
type TimeSlot struct {
ID uint `gorm:"primaryKey" json:"id"`
Date time.Time `gorm:"not null;index" json:"date"`
StartTime time.Time `gorm:"not null" json:"start_time"`
EndTime time.Time `gorm:"not null" json:"end_time"`
MaxPeople int `gorm:"not null;default:1" json:"max_people"` // 每个时间段最多预约人数
CurrentPeople int `gorm:"not null;default:0" json:"current_people"` // 当前已预约人数
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
type Appointment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"user_id"`
TimeSlotID uint `gorm:"not null" json:"time_slot_id"`
PeopleCount int `gorm:"not null;default:1" json:"people_count"` // 预约人数
Status string `gorm:"size:20;default:pending" json:"status"` // pending, confirmed, cancelled, completed
Notes string `gorm:"size:500" json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 关联
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
TimeSlot TimeSlot `gorm:"foreignKey:TimeSlotID" json:"time_slot,omitempty"`
}
// 预约状态枚举
const (
AppointmentPending = "pending"
AppointmentConfirmed = "confirmed"
AppointmentCancelled = "cancelled"
AppointmentCompleted = "completed"
)
// 用户角色枚举
const (
RoleUser = "user"
RoleAdmin = "admin"
)

42
utils/response.go Normal file
View File

@@ -0,0 +1,42 @@
package utils
import (
"github.com/gofiber/fiber/v2"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func Success(c *fiber.Ctx, data interface{}) error {
return c.JSON(Response{
Code: 200,
Message: "success",
Data: data,
})
}
func Error(c *fiber.Ctx, code int, message string) error {
return c.Status(code).JSON(Response{
Code: code,
Message: message,
})
}
func BadRequest(c *fiber.Ctx, message string) error {
return Error(c, fiber.StatusBadRequest, message)
}
func Unauthorized(c *fiber.Ctx, message string) error {
return Error(c, fiber.StatusUnauthorized, message)
}
func NotFound(c *fiber.Ctx, message string) error {
return Error(c, fiber.StatusNotFound, message)
}
func InternalServerError(c *fiber.Ctx, message string) error {
return Error(c, fiber.StatusInternalServerError, message)
}

View File

@@ -0,0 +1,81 @@
package utils
import (
"fmt"
"time"
"yuyue/database"
"yuyue/models"
)
// GenerateTwoMonthsTimeSlots 生成最近两个月的预约时间槽
func GenerateTwoMonthsTimeSlots() error {
// 计算日期范围:明天开始的两个月
now := time.Now()
startDate := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) // 明天
endDate := startDate.AddDate(0, 2, 0) // 两个月后
fmt.Printf("开始生成时间槽:%s 到 %s\n", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
generatedSlots := []models.TimeSlot{}
conflictCount := 0
currentDate := startDate
for !currentDate.After(endDate) {
// 排除周末
weekday := currentDate.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
currentDate = currentDate.AddDate(0, 0, 1)
continue
}
// 生成工作日的时间槽 (9:00-18:00每小时一个时段)
startTime := time.Date(currentDate.Year(), currentDate.Month(), currentDate.Day(),
9, 0, 0, 0, currentDate.Location())
endTime := time.Date(currentDate.Year(), currentDate.Month(), currentDate.Day(),
18, 0, 0, 0, currentDate.Location())
currentSlotTime := startTime
for currentSlotTime.Before(endTime) {
slotEnd := currentSlotTime.Add(time.Hour)
// 检查是否已经存在相同的时间槽
var existingSlot models.TimeSlot
err := database.GetDB().Where("date = ? AND start_time = ? AND end_time = ?",
currentDate, currentSlotTime, slotEnd).First(&existingSlot).Error
if err != nil {
// 不存在,创建新的时间槽
newSlot := models.TimeSlot{
Date: currentDate,
StartTime: currentSlotTime,
EndTime: slotEnd,
MaxPeople: 5, // 每个时段最多5人
CurrentPeople: 0,
IsActive: true,
}
generatedSlots = append(generatedSlots, newSlot)
} else {
conflictCount++
}
currentSlotTime = slotEnd
}
currentDate = currentDate.AddDate(0, 0, 1)
}
// 批量插入到数据库
if len(generatedSlots) > 0 {
if err := database.GetDB().CreateInBatches(generatedSlots, 100).Error; err != nil {
return fmt.Errorf("批量创建时间槽失败: %v", err)
}
fmt.Printf("成功生成 %d 个时间槽\n", len(generatedSlots))
}
if conflictCount > 0 {
fmt.Printf("跳过 %d 个已存在的时间槽\n", conflictCount)
}
fmt.Printf("总共处理 %d 个时间槽\n", len(generatedSlots)+conflictCount)
return nil
}