244 lines
6.5 KiB
Go
244 lines
6.5 KiB
Go
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")
|
||
}
|