13
This commit is contained in:
45
Dockerfile
Normal file
45
Dockerfile
Normal 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
394
README.md
Normal 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
55
config/config.go
Normal 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
51
database/database.go
Normal 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
37
go.mod
Normal 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
87
go.sum
Normal 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
240
handlers/appointment.go
Normal 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
210
handlers/auth.go
Normal 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
218
handlers/timeslot.go
Normal 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
100
main.go
Normal 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
68
middleware/auth.go
Normal 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
39
middleware/jwt.go
Normal 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
61
models/models.go
Normal 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
42
utils/response.go
Normal 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)
|
||||||
|
}
|
||||||
81
utils/timeslot_generator.go
Normal file
81
utils/timeslot_generator.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user