Files
hunli/hunliji-api/main.go
2026-07-30 17:57:41 +08:00

244 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}