package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "log" "mime/multipart" "net/url" "os" "path/filepath" "strings" "time" "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" "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 UploadConfig struct { ID uint `gorm:"primaryKey" json:"id"` Provider string `gorm:"column:provider;size:20" json:"provider"` AccessKeyID string `gorm:"column:access_key_id;size:255" json:"accessKeyId"` AccessKeySecret string `gorm:"column:access_key_secret;type:text" json:"-"` Bucket string `gorm:"column:bucket;size:255" json:"bucket"` Folder string `gorm:"column:folder;size:255" json:"folder"` Domain string `gorm:"column:domain;size:255" json:"domain"` Region string `gorm:"column:region;size:100" json:"region"` Endpoint string `gorm:"column:endpoint;size:255" json:"endpoint"` 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"` 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 } 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 requireAdmin(c *gin.Context) bool { if !verifyToken(bearerToken(c)) { c.JSON(401, gin.H{"error": "未授权,请先登录后台"}) return false } return true } func initDB() { dsn := "root:mysql_PKC65h@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{}, &UploadConfig{}, &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 defaultUploadConfig() UploadConfig { return UploadConfig{ID: 1, Provider: "local"} } func loadUploadConfig() UploadConfig { var config UploadConfig if err := db.First(&config, 1).Error; err != nil { config = defaultUploadConfig() _ = db.Create(&config).Error } if config.Provider == "" { config.Provider = "local" } return config } func uploadConfigResponse(config UploadConfig) gin.H { return gin.H{ "provider": config.Provider, "accessKeyId": config.AccessKeyID, "hasSecret": config.AccessKeySecret != "", "bucket": config.Bucket, "folder": config.Folder, "domain": config.Domain, "region": config.Region, "endpoint": config.Endpoint, "updated_at": config.UpdatedAt, } } func cleanObjectName(name string) string { name = filepath.Base(name) name = strings.ReplaceAll(name, "\\", "_") name = strings.ReplaceAll(name, "/", "_") name = strings.TrimSpace(name) if name == "" || name == "." { return "upload" } return name } func buildObjectKey(folder string, originalName string) string { folder = strings.Trim(folder, "/ ") name := cleanObjectName(originalName) fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name) day := time.Now().Format("20060102") if folder == "" { return day + "/" + fileName } return folder + "/" + day + "/" + fileName } func escapedObjectKey(key string) string { parts := strings.Split(key, "/") for i, part := range parts { parts[i] = url.PathEscape(part) } return strings.Join(parts, "/") } func normalizeEndpoint(endpoint string, region string) string { endpoint = strings.TrimSpace(endpoint) region = strings.TrimSpace(region) if endpoint == "" && region != "" { endpoint = "https://oss-" + region + ".aliyuncs.com" } if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { endpoint = "https://" + endpoint } return endpoint } func objectURL(config UploadConfig, objectKey string) string { escapedKey := escapedObjectKey(objectKey) if strings.TrimSpace(config.Domain) != "" { domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/") if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { domain = "https://" + domain } return domain + "/" + escapedKey } endpoint := strings.TrimRight(normalizeEndpoint(config.Endpoint, config.Region), "/") u, err := url.Parse(endpoint) if err == nil && u.Host != "" { return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey } if endpoint == "" { return escapedKey } return endpoint + "/" + escapedKey } func canUseOSS(config UploadConfig) bool { return config.Provider == "oss" && strings.TrimSpace(config.AccessKeyID) != "" && strings.TrimSpace(config.AccessKeySecret) != "" && strings.TrimSpace(config.Bucket) != "" && (strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "") } func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) { filename := fmt.Sprintf("%d_%s", time.Now().Unix(), cleanObjectName(file.Filename)) savePath := filepath.Join("uploads", filename) if err := c.SaveUploadedFile(file, savePath); err != nil { c.JSON(500, gin.H{"error": "文件保存至服务器失败"}) return } fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename) c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"}) } func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config UploadConfig) bool { file, err := fileHeader.Open() if err != nil { c.JSON(500, gin.H{"error": "文件打开失败"}) return false } defer file.Close() endpoint := normalizeEndpoint(config.Endpoint, config.Region) provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret) ossConfig := oss.LoadDefaultConfig(). WithCredentialsProvider(provider). WithRegion(config.Region). WithEndpoint(endpoint) client := oss.NewClient(ossConfig) objectKey := buildObjectKey(config.Folder, fileHeader.Filename) _, err = client.PutObject(context.Background(), &oss.PutObjectRequest{ Bucket: oss.Ptr(config.Bucket), Key: oss.Ptr(objectKey), Acl: oss.ObjectACLPublicRead, Body: file, }) if err != nil { c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()}) return false } c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey}) return true } func collectAlbumImages(configData string) []string { var raw map[string]interface{} if err := json.Unmarshal([]byte(configData), &raw); err != nil { return []string{} } seen := map[string]bool{} images := []string{} add := func(value interface{}) { src, ok := value.(string) if !ok { return } src = strings.TrimSpace(src) if src == "" || seen[src] { return } seen[src] = true images = append(images, src) } add(raw["heroImg"]) add(raw["endImg"]) if pages, ok := raw["photoPages"].([]interface{}); ok { for _, item := range pages { page, ok := item.(map[string]interface{}) if !ok { continue } add(page["img1"]) add(page["img2"]) if pageImages, ok := page["images"].([]interface{}); ok { for _, src := range pageImages { add(src) } } } } return images } func main() { initDB() r := gin.Default() r.Use(corsMiddleware()) r.Static("/uploads", "./uploads") api := r.Group("/api") { 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}, }) }) 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}) }) api.GET("/album", func(c *gin.Context) { var config TemplateConfig if err := db.First(&config, 1).Error; err != nil { c.JSON(200, gin.H{"code": 200, "data": []string{}}) return } c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)}) }) api.POST("/config", func(c *gin.Context) { if !requireAdmin(c) { 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": "配置保存成功"}) }) api.GET("/upload/config", func(c *gin.Context) { if !requireAdmin(c) { return } c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())}) }) api.POST("/upload/config", func(c *gin.Context) { if !requireAdmin(c) { return } var body struct { Provider string `json:"provider"` AccessKeyID string `json:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret"` Bucket string `json:"bucket"` Folder string `json:"folder"` Domain string `json:"domain"` Region string `json:"region"` Endpoint string `json:"endpoint"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } config := loadUploadConfig() provider := strings.TrimSpace(body.Provider) if provider != "oss" { provider = "local" } config.Provider = provider config.AccessKeyID = strings.TrimSpace(body.AccessKeyID) if strings.TrimSpace(body.AccessKeySecret) != "" { config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret) } config.Bucket = strings.TrimSpace(body.Bucket) config.Folder = strings.Trim(body.Folder, "/ ") config.Domain = strings.TrimSpace(body.Domain) config.Region = strings.TrimSpace(body.Region) config.Endpoint = strings.TrimSpace(body.Endpoint) if err := db.Save(&config).Error; err != nil { c.JSON(500, gin.H{"error": "上传配置保存失败"}) return } c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"}) }) api.POST("/rsvp", func(c *gin.Context) { var rsvp Rsvp 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": "回执提交成功"}) }) api.POST("/upload", func(c *gin.Context) { if !requireAdmin(c) { return } file, err := c.FormFile("file") if err != nil { c.JSON(400, gin.H{"error": "未获取到上传文件"}) return } config := loadUploadConfig() if canUseOSS(config) { saveOSSUpload(c, file, config) return } saveLocalUpload(c, file) }) api.GET("/rsvp/list", func(c *gin.Context) { if !requireAdmin(c) { 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:15201 启动") r.Run(":15201") }