package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/driver/mysql" "gorm.io/gorm" ) // --- 数据库模型 --- type TemplateConfig struct { ID uint `gorm:"primaryKey" json:"id"` ConfigData string `gorm:"type:json;column:config_data" json:"config_data"` UpdatedAt time.Time `json:"updated_at"` } type Rsvp struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"column:name;size:50" json:"name"` GuestCount string `gorm:"column:guest_count;size:20" json:"guest_count"` // 必须与前端 guest_count 匹配 Wishes string `gorm:"column:wishes;type:text" json:"wishes"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` } var db *gorm.DB // 后台管理员凭据与签名密钥(可用环境变量覆盖) var ( adminAccount = getEnv("ADMIN_ACCOUNT", "admin") adminPassword = getEnv("ADMIN_PASSWORD", "admin123") adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") ) func getEnv(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } // 签发后台令牌:base64(payload).base64(hmac(payload, secret)) func signToken(account string) string { payload := fmt.Sprintf(`{"account":%q,"exp":%d}`, account, time.Now().Add(24*time.Hour).Unix()) b64 := base64.StdEncoding.EncodeToString([]byte(payload)) mac := hmac.New(sha256.New, []byte(adminSecret)) mac.Write([]byte(b64)) sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return b64 + "." + sig } // 校验后台令牌 func verifyToken(token string) bool { parts := strings.Split(token, ".") if len(parts) != 2 { return false } mac := hmac.New(sha256.New, []byte(adminSecret)) mac.Write([]byte(parts[0])) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(parts[1])) { return false } raw, err := base64.StdEncoding.DecodeString(parts[0]) if err != nil { return false } var claims struct { Account string `json:"account"` Exp int64 `json:"exp"` } if err := json.Unmarshal(raw, &claims); err != nil { return false } return time.Now().Unix() <= claims.Exp } func bearerToken(c *gin.Context) string { return strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") } func initDB() { // DSN: 用户名:密码@tcp(地址:端口)/数据库名 dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" var err error db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("数据库连接失败: %v", err) } // 自动同步表结构 _ = db.AutoMigrate(&TemplateConfig{}, &Rsvp{}) // 确保上传目录存在 _ = os.MkdirAll("./uploads", os.ModePerm) fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪") } func corsMiddleware() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() } } func main() { initDB() r := gin.Default() r.Use(corsMiddleware()) // 静态文件目录,用于预览上传的图片 r.Static("/uploads", "./uploads") api := r.Group("/api") { // 0. 后台登录 api.POST("/admin/login", func(c *gin.Context) { var body struct { Account string `json:"account"` Password string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } if body.Account != adminAccount || body.Password != adminPassword { c.JSON(401, gin.H{"error": "账号或密码错误"}) return } c.JSON(200, gin.H{ "code": 200, "token": signToken(body.Account), "user": gin.H{"account": body.Account}, }) }) // 1. 获取配置 api.GET("/config", func(c *gin.Context) { var config TemplateConfig if err := db.First(&config, 1).Error; err != nil { c.JSON(200, gin.H{"code": 200, "data": "{}"}) return } c.JSON(200, gin.H{"code": 200, "data": config.ConfigData}) }) // 2. 保存配置(需后台鉴权) api.POST("/config", func(c *gin.Context) { if !verifyToken(bearerToken(c)) { c.JSON(401, gin.H{"error": "未授权,请先登录后台"}) return } var rawBody interface{} if err := c.BindJSON(&rawBody); err != nil { c.JSON(400, gin.H{"error": "无效的JSON数据"}) return } jsonBytes, _ := json.Marshal(rawBody) var config TemplateConfig db.FirstOrCreate(&config, 1) config.ConfigData = string(jsonBytes) if err := db.Save(&config).Error; err != nil { c.JSON(500, gin.H{"error": "数据库保存配置失败"}) return } c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"}) }) // 3. 提交出席信息 api.POST("/rsvp", func(c *gin.Context) { var rsvp Rsvp // BindJSON 会根据 json 标签映射字段 if err := c.ShouldBindJSON(&rsvp); err != nil { c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) return } if err := db.Create(&rsvp).Error; err != nil { c.JSON(500, gin.H{"error": "出席信息写入数据库失败"}) return } c.JSON(200, gin.H{"code": 200, "msg": "回执提交成功"}) }) // 4. 图片上传接口 (补全) api.POST("/upload", func(c *gin.Context) { file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "未获取到上传文件"}) return } // 生成唯一文件名 filename := fmt.Sprintf("%d_%s", time.Now().Unix(), filepath.Base(file.Filename)) savePath := filepath.Join("uploads", filename) if err := c.SaveUploadedFile(file, savePath); err != nil { c.JSON(500, gin.H{"error": "文件保存至服务器失败"}) return } // 返回前端可访问的完整 URL fileUrl := fmt.Sprintf("http://localhost:8080/uploads/%s", filename) c.JSON(200, gin.H{ "code": 200, "url": fileUrl, }) }) // 5. 出席回执列表(需后台鉴权) api.GET("/rsvp/list", func(c *gin.Context) { if !verifyToken(bearerToken(c)) { c.JSON(401, gin.H{"error": "未授权,请先登录后台"}) return } var list []Rsvp db.Order("created_at desc").Find(&list) c.JSON(200, gin.H{"code": 200, "data": list}) }) } fmt.Println("婚礼纪后端 API 已在 http://localhost:8080 启动") r.Run(":8080") }