From 13d1175057a6606388e52560978bee92b7c89dce Mon Sep 17 00:00:00 2001 From: lingxiao865 <1060369102@qq.com> Date: Tue, 10 Feb 2026 09:30:37 +0800 Subject: [PATCH] 13 --- Dockerfile | 45 ++++ README.md | 394 ++++++++++++++++++++++++++++++++++++ config/config.go | 55 +++++ database/database.go | 51 +++++ go.mod | 37 ++++ go.sum | 87 ++++++++ handlers/appointment.go | 240 ++++++++++++++++++++++ handlers/auth.go | 210 +++++++++++++++++++ handlers/timeslot.go | 218 ++++++++++++++++++++ main.go | 100 +++++++++ middleware/auth.go | 68 +++++++ middleware/jwt.go | 39 ++++ models/models.go | 61 ++++++ utils/response.go | 42 ++++ utils/timeslot_generator.go | 81 ++++++++ 15 files changed, 1728 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/config.go create mode 100644 database/database.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers/appointment.go create mode 100644 handlers/auth.go create mode 100644 handlers/timeslot.go create mode 100644 main.go create mode 100644 middleware/auth.go create mode 100644 middleware/jwt.go create mode 100644 models/models.go create mode 100644 utils/response.go create mode 100644 utils/timeslot_generator.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7240373 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..54ef8e0 --- /dev/null +++ b/README.md @@ -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 +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 +``` + +#### 更新用户信息 +```http +PUT /api/users/profile +Authorization: Bearer +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 +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 +Content-Type: application/json + +{ + "time_slot_id": 1, + "people_count": 2, + "notes": "预约备注" +} +``` + +#### 获取预约列表 +```http +GET /api/appointments +Authorization: Bearer +# 可选参数: ?status=pending&date=2026-02-10 +``` + +#### 获取单个预约 +```http +GET /api/appointments/{id} +Authorization: Bearer +``` + +#### 取消预约 +```http +DELETE /api/appointments/{id} +Authorization: Bearer +``` + +#### 更新预约状态 (管理员) +```http +PUT /api/admin/appointments/{id}/status +Authorization: Bearer +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) \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..2424618 --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..dfcbe5d --- /dev/null +++ b/database/database.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a434940 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4e86619 --- /dev/null +++ b/go.sum @@ -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= diff --git a/handlers/appointment.go b/handlers/appointment.go new file mode 100644 index 0000000..b187f2f --- /dev/null +++ b/handlers/appointment.go @@ -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) +} diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..62654c0 --- /dev/null +++ b/handlers/auth.go @@ -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) +} diff --git a/handlers/timeslot.go b/handlers/timeslot.go new file mode 100644 index 0000000..6a4c121 --- /dev/null +++ b/handlers/timeslot.go @@ -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": "时间槽删除成功"}) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d5f8190 --- /dev/null +++ b/main.go @@ -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": "预约系统运行正常", + }) + }) +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..b141851 --- /dev/null +++ b/middleware/auth.go @@ -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 "" +} diff --git a/middleware/jwt.go b/middleware/jwt.go new file mode 100644 index 0000000..d0920f3 --- /dev/null +++ b/middleware/jwt.go @@ -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 +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..8a246ee --- /dev/null +++ b/models/models.go @@ -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" +) diff --git a/utils/response.go b/utils/response.go new file mode 100644 index 0000000..1816418 --- /dev/null +++ b/utils/response.go @@ -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) +} diff --git a/utils/timeslot_generator.go b/utils/timeslot_generator.go new file mode 100644 index 0000000..a8b1b54 --- /dev/null +++ b/utils/timeslot_generator.go @@ -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 +}