package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log" "mime/multipart" "net/url" "os" "path/filepath" "strings" "time" "hunliji-api/spark" "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "gorm.io/driver/mysql" "gorm.io/gorm" ) type TemplateConfig struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}';comment:请柬配置JSON" json:"config_data"` UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` } func (TemplateConfig) TableName() string { return "template_configs" } type UploadConfig struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"` AccessKeyID string `gorm:"column:access_key_id;size:255;not null;default:'';comment:OSS AccessKeyId" json:"accessKeyId"` AccessKeySecret string `gorm:"column:access_key_secret;type:text;comment:OSS AccessKeySecret" json:"-"` Bucket string `gorm:"column:bucket;size:255;not null;default:'';comment:OSS Bucket" json:"bucket"` Folder string `gorm:"column:folder;size:255;not null;default:'';comment:上传目录前缀" json:"folder"` Domain string `gorm:"column:domain;size:255;not null;default:'';comment:访问域名" json:"domain"` Region string `gorm:"column:region;size:100;not null;default:'';comment:OSS Region" json:"region"` Endpoint string `gorm:"column:endpoint;size:255;not null;default:'';comment:OSS Endpoint" json:"endpoint"` UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` } func (UploadConfig) TableName() string { return "upload_configs" } type Rsvp struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"` GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"` Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"` CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"` } func (Rsvp) TableName() string { return "rsvps" } type Danmaku struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"` Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"` Color string `gorm:"column:color;size:20;not null;default:champagne;comment:弹幕颜色key" json:"color"` Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"` RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"` Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"` CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` } func (Danmaku) TableName() string { return "danmakus" } type SiteLike struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"` } func (SiteLike) TableName() string { return "site_likes" } // AiModerationCache 缓存 AI 审核通过/失败结果,命中则跳过星火调用 type AiModerationCache struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Content string `gorm:"column:content;type:text;not null;comment:姓名+祝福原文" json:"content"` ContentHash string `gorm:"column:content_hash;size:64;not null;index:idx_ai_mod_type_hash,priority:2;comment:content的SHA256" json:"content_hash"` Type string `gorm:"column:type;size:20;not null;index:idx_ai_mod_type_hash,priority:1;comment:类型 danmaku|rsvp" json:"type"` Status string `gorm:"column:status;size:20;not null;default:rejected;comment:审核状态 approved|rejected" json:"status"` Reason string `gorm:"column:reason;size:255;not null;default:'';comment:拒绝原因" json:"reason"` CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` } func (AiModerationCache) TableName() string { return "ai_moderation_caches" } func moderationCacheContent(name, content string) string { return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content) } func hashModerationContent(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } func saveModerationCache(typ, content, status, reason string) { row := AiModerationCache{ Content: content, ContentHash: hashModerationContent(content), Type: typ, Status: status, Reason: reason, } if err := db.Create(&row).Error; err != nil { log.Printf("写入审核缓存失败: %v", err) } } var danmakuColorWhitelist = map[string]struct{}{ "champagne": {}, "blush": {}, "apricot": {}, "gold": {}, "lilac": {}, "sky": {}, "mauve": {}, "slate": {}, "ink": {}, } func normalizeDanmakuColor(color string) string { c := strings.TrimSpace(color) if c == "rose" || c == "sage" { return "champagne" } if _, ok := danmakuColorWhitelist[c]; ok { return c } return "champagne" } var db *gorm.DB var sparkClient *spark.Client var ( adminAccount = getEnv("ADMIN_ACCOUNT", "admin") adminPassword = getEnv("ADMIN_PASSWORD", "admin123") adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") ) func moderateOrPass(name, content, typ string) (bool, string) { guest := strings.TrimSpace(name) body := strings.TrimSpace(content) if body == "" { return true, "" } cacheKey := moderationCacheContent(guest, body) cacheHash := hashModerationContent(cacheKey) var cached AiModerationCache err := db.Where("type = ? AND content_hash = ?", typ, cacheHash). Order("id desc"). First(&cached).Error if err == nil { if cached.Status == "approved" { return true, "" } reason := strings.TrimSpace(cached.Reason) if reason == "" { reason = "内容未通过审核" } return false, reason } if sparkClient == nil || !sparkClient.Enabled() { return true, "" } ok, reason, err := sparkClient.ModerateText(guest, body) if err != nil { log.Printf("AI 审核失败,放行: %v", err) return true, "" } if !ok { if strings.TrimSpace(reason) == "" { reason = "内容未通过审核" } saveModerationCache(typ, cacheKey, "rejected", reason) return false, reason } saveModerationCache(typ, cacheKey, "approved", "") return true, "" } 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 escapeMySQLComment(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `'`, `''`) return s } func setTableComment(db *gorm.DB, table, comment string) error { // MySQL 不支持 COMMENT 占位符绑定,需直接拼进 SQL sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, escapeMySQLComment(comment)) return db.Exec(sql).Error } func migrateSchema(db *gorm.DB) { tables := []struct { model interface{} comment string name string }{ {&TemplateConfig{}, "请柬模板配置", "template_configs"}, {&UploadConfig{}, "上传配置", "upload_configs"}, {&Rsvp{}, "出席回执", "rsvps"}, {&Danmaku{}, "祝福弹幕", "danmakus"}, {&SiteLike{}, "站点点赞", "site_likes"}, {&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, } for _, t := range tables { opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment)) if err := db.Set("gorm:table_options", opts).AutoMigrate(t.model); err != nil { log.Printf("AutoMigrate 失败 %s: %v", t.name, err) } // 已存在的表不会吃到 table_options,必须再 ALTER 一次写表注释 if err := setTableComment(db, t.name, t.comment); err != nil { log.Printf("设置表注释失败 %s: %v", t.name, err) } else { log.Printf("表注释已更新 %s => %s", t.name, t.comment) } } // 取消点赞一人一次:去掉 client_id 唯一索引(若存在) if db.Migrator().HasIndex(&SiteLike{}, "idx_site_likes_client_id") { _ = db.Migrator().DropIndex(&SiteLike{}, "idx_site_likes_client_id") } } func initDB() { dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" //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) } migrateSchema(db) _ = 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() { _ = godotenv.Load() adminAccount = getEnv("ADMIN_ACCOUNT", "admin") adminPassword = getEnv("ADMIN_PASSWORD", "admin123") adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") sparkClient = spark.NewFromEnv() 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": json.RawMessage(`{}`)}) return } raw := strings.TrimSpace(config.ConfigData) if raw == "" { raw = "{}" } // ConfigData 在库里是 JSON 文本;用 RawMessage 嵌入,避免再被编码成带转义的字符串 c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)}) }) 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("/ai/blessing", func(c *gin.Context) { if sparkClient == nil || !sparkClient.Enabled() { c.JSON(503, gin.H{"error": "AI 未配置"}) return } var body struct { Name string `json:"name"` Style string `json:"style"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } if _, ok := spark.NormalizeStyle(body.Style); !ok { c.JSON(400, gin.H{"error": "请选择祝福风格"}) return } text, err := sparkClient.GenerateBlessing(body.Name, body.Style) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(200, gin.H{"code": 200, "data": gin.H{"text": text}}) }) 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 } wishes := strings.TrimSpace(rsvp.Wishes) if wishes != "" { ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp") if !ok { c.JSON(400, gin.H{"error": "内容未通过审核:" + reason}) return } } if err := db.Create(&rsvp).Error; err != nil { c.JSON(500, gin.H{"error": "出席信息写入数据库失败"}) return } var danmaku *Danmaku if wishes != "" { rid := rsvp.ID item := Danmaku{ Name: rsvp.Name, Content: wishes, Color: "champagne", Source: "rsvp", RsvpID: &rid, Status: "approved", } if err := db.Create(&item).Error; err == nil { danmaku = &item } } resp := gin.H{"code": 200, "msg": "回执提交成功"} if danmaku != nil { resp["danmaku"] = danmaku } c.JSON(200, resp) }) api.GET("/danmaku", func(c *gin.Context) { q := db.Where("status = ?", "approved") if after := strings.TrimSpace(c.Query("after_id")); after != "" { var afterID uint64 if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 { q = q.Where("id > ?", afterID) } } var list []Danmaku q.Order("id asc").Find(&list) c.JSON(200, gin.H{"code": 200, "data": list}) }) api.POST("/danmaku", func(c *gin.Context) { var body struct { Name string `json:"name"` Content string `json:"content"` Color string `json:"color"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) return } name := strings.TrimSpace(body.Name) content := strings.TrimSpace(body.Content) if name == "" || content == "" { c.JSON(400, gin.H{"error": "请填写姓名和祝福"}) return } if len([]rune(name)) > 50 { c.JSON(400, gin.H{"error": "姓名过长"}) return } ok, reason := moderateOrPass(name, content, "danmaku") if !ok { c.JSON(400, gin.H{"error": "内容未通过审核:" + reason}) return } item := Danmaku{ Name: name, Content: content, Color: normalizeDanmakuColor(body.Color), Source: "danmaku", Status: "approved", } if err := db.Create(&item).Error; err != nil { c.JSON(500, gin.H{"error": "祝福提交失败"}) return } c.JSON(200, gin.H{"code": 200, "data": item, "msg": "发送成功"}) }) api.GET("/danmaku/list", func(c *gin.Context) { if !requireAdmin(c) { return } q := db.Order("created_at desc") if status := strings.TrimSpace(c.Query("status")); status != "" { q = q.Where("status = ?", status) } var list []Danmaku q.Find(&list) c.JSON(200, gin.H{"code": 200, "data": list}) }) api.POST("/danmaku/:id/status", func(c *gin.Context) { if !requireAdmin(c) { return } var body struct { Status string `json:"status"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } status := strings.TrimSpace(body.Status) if status != "approved" && status != "rejected" && status != "pending" { c.JSON(400, gin.H{"error": "无效的状态"}) return } id := c.Param("id") res := db.Model(&Danmaku{}).Where("id = ?", id).Update("status", status) if res.Error != nil { c.JSON(500, gin.H{"error": "更新失败"}) return } if res.RowsAffected == 0 { c.JSON(404, 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}) }) api.GET("/like", func(c *gin.Context) { var count int64 db.Model(&SiteLike{}).Count(&count) c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}}) }) api.POST("/like", func(c *gin.Context) { var body struct { ClientID string `json:"client_id"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"error": "参数错误"}) return } clientID := strings.TrimSpace(body.ClientID) if clientID == "" || len(clientID) > 64 { c.JSON(400, gin.H{"error": "无效的 client_id"}) return } if err := db.Create(&SiteLike{ClientID: clientID}).Error; err != nil { c.JSON(500, gin.H{"error": "点赞失败"}) return } var count int64 db.Model(&SiteLike{}).Count(&count) c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"}) }) } fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动") r.Run(":15201") }