diff --git a/hunliji-api/configsection/section.go b/hunliji-api/configsection/section.go new file mode 100644 index 0000000..4207981 --- /dev/null +++ b/hunliji-api/configsection/section.go @@ -0,0 +1,148 @@ +package configsection + +import ( + "encoding/json" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type templateConfig struct { + ID uint `gorm:"primaryKey"` + ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}'"` +} + +func (templateConfig) TableName() string { return "template_configs" } + +var ( + db *gorm.DB + requireAdmin func(*gin.Context) bool +) + +var configSectionKeys = map[string][]string{ + "basic": { + "groom", "bride", "date", "lunar", "calendarDate", "hotel", "address", "heroImg", "endImg", + }, + "copy": { + "formalText1", "formalText2", "formalPageHeight", + }, + "visual": { + "introExitEffect", "scrollMode", "freeScrollInterval", "pageSwitchDuration", "firstScreenDuration", + "petalEnabled", "bubbleEnabled", "introEnabled", "danmakuEnabled", "danmakuShowTime", "nineGridAnimate", + }, + "music": { + "musicList", "activeMusicUrl", "musicRandomEnabled", + }, + "photos": { + "photoPages", + }, + "schedule": { + "schedule", + }, +} + +// Register 由 main 入口调用:注入依赖并挂载分段配置路由 +func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { + db = database + requireAdmin = adminGuard + api.GET("/config/section", handleGetConfigSection) + api.POST("/config/section", handleSaveConfigSection) +} + +func loadConfigMap() (map[string]interface{}, error) { + var config templateConfig + if err := db.First(&config, 1).Error; err != nil { + return map[string]interface{}{}, nil + } + raw := strings.TrimSpace(config.ConfigData) + if raw == "" { + raw = "{}" + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return nil, err + } + if m == nil { + m = map[string]interface{}{} + } + return m, nil +} + +func saveConfigMap(m map[string]interface{}) error { + jsonBytes, err := json.Marshal(m) + if err != nil { + return err + } + var config templateConfig + db.FirstOrCreate(&config, 1) + config.ConfigData = string(jsonBytes) + return db.Save(&config).Error +} + +func pickSection(full map[string]interface{}, keys []string) map[string]interface{} { + out := make(map[string]interface{}, len(keys)) + for _, k := range keys { + if v, ok := full[k]; ok { + out[k] = v + } + } + return out +} + +func handleGetConfigSection(c *gin.Context) { + if !requireAdmin(c) { + return + } + section := strings.TrimSpace(c.Query("section")) + keys, ok := configSectionKeys[section] + if !ok { + c.JSON(400, gin.H{"error": "未知的 section"}) + return + } + full, err := loadConfigMap() + if err != nil { + c.JSON(500, gin.H{"error": "读取配置失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "data": pickSection(full, keys)}) +} + +func handleSaveConfigSection(c *gin.Context) { + if !requireAdmin(c) { + return + } + var body struct { + Section string `json:"section"` + Data map[string]interface{} `json:"data"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + section := strings.TrimSpace(body.Section) + keys, ok := configSectionKeys[section] + if !ok { + c.JSON(400, gin.H{"error": "未知的 section"}) + return + } + if body.Data == nil { + body.Data = map[string]interface{}{} + } + + full, err := loadConfigMap() + if err != nil { + c.JSON(500, gin.H{"error": "读取配置失败"}) + return + } + for _, k := range keys { + if v, ok := body.Data[k]; ok { + full[k] = v + } + } + if err := saveConfigMap(full); err != nil { + c.JSON(500, gin.H{"error": "保存配置失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "配置段已保存", "data": pickSection(full, keys)}) +} diff --git a/hunliji-api/hl-api b/hunliji-api/hl-api index 200e855..d3b76c4 100644 Binary files a/hunliji-api/hl-api and b/hunliji-api/hl-api differ diff --git a/hunliji-api/main.go b/hunliji-api/main.go index b370aa3..4600cb6 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -1,264 +1,24 @@ 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/models" + "hunliji-api/router" "hunliji-api/spark" + "hunliji-api/utils" + "hunliji-api/visit" - "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:32;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": {}, - "gradSunset": {}, - "gradChampagne": {}, - "gradBlush": {}, - "gradOcean": {}, - "gradAurora": {}, - "gradEmber": {}, -} - -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)) + sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, utils.EscapeMySQLComment(comment)) return db.Exec(sql).Error } @@ -268,594 +28,60 @@ func migrateSchema(db *gorm.DB) { comment string name string }{ - {&TemplateConfig{}, "请柬模板配置", "template_configs"}, - {&UploadConfig{}, "上传配置", "upload_configs"}, - {&Rsvp{}, "出席回执", "rsvps"}, - {&Danmaku{}, "祝福弹幕", "danmakus"}, - {&SiteLike{}, "站点点赞", "site_likes"}, - {&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, + {&models.TemplateConfig{}, "请柬模板配置", "template_configs"}, + {&models.UploadConfig{}, "上传配置", "upload_configs"}, + {&models.Rsvp{}, "出席回执", "rsvps"}, + {&models.Danmaku{}, "祝福弹幕", "danmakus"}, + {&models.SiteLike{}, "站点点赞", "site_likes"}, + {&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, + {&visit.SiteVisit{}, "访客记录", "site_visits"}, } for _, t := range tables { - opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment)) + opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", utils.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") + if db.Migrator().HasIndex(&models.SiteLike{}, "idx_site_likes_client_id") { + _ = db.Migrator().DropIndex(&models.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{}) +func initDB() *gorm.DB { + //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" + database, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("数据库连接失败: %v", err) } - - migrateSchema(db) + migrateSchema(database) _ = 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 + return database } 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() + adminAccount := utils.GetEnv("ADMIN_ACCOUNT", "admin") + adminPassword := utils.GetEnv("ADMIN_PASSWORD", "admin123") + adminSecret := utils.GetEnv("ADMIN_SECRET", "wedding-admin-secret-2026") + utils.InitAuth(adminSecret) + + database := 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 { - 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.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": "点赞成功"}) - }) - } + router.Register(r, router.Deps{ + DB: database, + Spark: spark.NewFromEnv(), + AdminAccount: adminAccount, + AdminPassword: adminPassword, + }) fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动") - r.Run(":15201") + _ = r.Run(":15201") } diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go new file mode 100644 index 0000000..add44ee --- /dev/null +++ b/hunliji-api/models/models.go @@ -0,0 +1,70 @@ +package models + +import "time" + +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:32;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" } diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go new file mode 100644 index 0000000..f21bce4 --- /dev/null +++ b/hunliji-api/router/routers.go @@ -0,0 +1,603 @@ +package router + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "mime/multipart" + "net/url" + "path/filepath" + "strings" + "time" + + "hunliji-api/configsection" + "hunliji-api/models" + "hunliji-api/spark" + "hunliji-api/utils" + "hunliji-api/visit" + + "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/gorm" +) + +type Deps struct { + DB *gorm.DB + Spark *spark.Client + AdminAccount string + AdminPassword string +} + +var ( + db *gorm.DB + sparkClient *spark.Client + adminAccount string + adminPassword string +) + +var danmakuColorWhitelist = map[string]struct{}{ + "champagne": {}, "blush": {}, "apricot": {}, "gold": {}, "lilac": {}, + "sky": {}, "mauve": {}, "slate": {}, "ink": {}, + "gradSunset": {}, "gradChampagne": {}, "gradBlush": {}, + "gradOcean": {}, "gradAurora": {}, "gradEmber": {}, +} + +// Register 挂载静态资源、中间件与全部 /api 路由 +func Register(r *gin.Engine, deps Deps) { + db = deps.DB + sparkClient = deps.Spark + adminAccount = deps.AdminAccount + adminPassword = deps.AdminPassword + + r.Use(utils.CORSMiddleware()) + r.Static("/uploads", "./uploads") + + api := r.Group("/api") + registerAPI(api) +} + +func registerAPI(api *gin.RouterGroup) { + api.POST("/admin/login", handleAdminLogin) + api.GET("/config", handleGetConfig) + api.POST("/config", handleSaveConfig) + api.GET("/album", handleAlbum) + api.GET("/upload/config", handleGetUploadConfig) + api.POST("/upload/config", handleSaveUploadConfig) + api.POST("/upload", handleUpload) + api.POST("/ai/blessing", handleAIBlessing) + api.POST("/rsvp", handleCreateRsvp) + api.GET("/rsvp/list", handleRsvpList) + api.GET("/danmaku", handlePublicDanmaku) + api.POST("/danmaku", handleCreateDanmaku) + api.GET("/danmaku/list", handleDanmakuList) + api.POST("/danmaku/:id/status", handleDanmakuStatus) + api.GET("/like", handleLikeCount) + api.POST("/like", handleLike) + + visit.Register(api, db, utils.RequireAdmin) + configsection.Register(api, db, utils.RequireAdmin) +} + +func handleAdminLogin(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": utils.SignToken(body.Account), + "user": gin.H{"account": body.Account}, + }) +} + +func handleGetConfig(c *gin.Context) { + var config models.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 = "{}" + } + c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)}) +} + +func handleSaveConfig(c *gin.Context) { + if !utils.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 models.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": "配置保存成功"}) +} + +func handleAlbum(c *gin.Context) { + var config models.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)}) +} + +func handleGetUploadConfig(c *gin.Context) { + if !utils.RequireAdmin(c) { + return + } + c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())}) +} + +func handleSaveUploadConfig(c *gin.Context) { + if !utils.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": "上传配置保存成功"}) +} + +func handleUpload(c *gin.Context) { + if !utils.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) +} + +func handleAIBlessing(c *gin.Context) { + if sparkClient == nil || !sparkClient.Enabled() { + c.JSON(503, gin.H{"error": "AI 未配置"}) + return + } + var body struct { + 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.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}}) +} + +func handleCreateRsvp(c *gin.Context) { + var rsvp models.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 *models.Danmaku + if wishes != "" { + rid := rsvp.ID + item := models.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) +} + +func handleRsvpList(c *gin.Context) { + if !utils.RequireAdmin(c) { + return + } + var list []models.Rsvp + db.Order("created_at desc").Find(&list) + c.JSON(200, gin.H{"code": 200, "data": list}) +} + +func handlePublicDanmaku(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 []models.Danmaku + q.Order("id asc").Find(&list) + c.JSON(200, gin.H{"code": 200, "data": list}) +} + +func handleCreateDanmaku(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 := models.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": "发送成功"}) +} + +func handleDanmakuList(c *gin.Context) { + if !utils.RequireAdmin(c) { + return + } + q := db.Order("created_at desc") + if status := strings.TrimSpace(c.Query("status")); status != "" { + q = q.Where("status = ?", status) + } + var list []models.Danmaku + q.Find(&list) + c.JSON(200, gin.H{"code": 200, "data": list}) +} + +func handleDanmakuStatus(c *gin.Context) { + if !utils.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(&models.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": "状态已更新"}) +} + +func handleLikeCount(c *gin.Context) { + var count int64 + db.Model(&models.SiteLike{}).Count(&count) + c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}}) +} + +func handleLike(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(&models.SiteLike{ClientID: clientID}).Error; err != nil { + c.JSON(500, gin.H{"error": "点赞失败"}) + return + } + var count int64 + db.Model(&models.SiteLike{}).Count(&count) + c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"}) +} + +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" +} + +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 := models.AiModerationCache{ + Content: content, ContentHash: hashModerationContent(content), + Type: typ, Status: status, Reason: reason, + } + if err := db.Create(&row).Error; err != nil { + log.Printf("写入审核缓存失败: %v", err) + } +} + +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 models.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 defaultUploadConfig() models.UploadConfig { + return models.UploadConfig{ID: 1, Provider: "local"} +} + +func loadUploadConfig() models.UploadConfig { + var config models.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 models.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 objectURL(config models.UploadConfig, objectKey string) string { + escapedKey := utils.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(utils.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 models.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(), utils.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 models.UploadConfig) bool { + file, err := fileHeader.Open() + if err != nil { + c.JSON(500, gin.H{"error": "文件打开失败"}) + return false + } + defer file.Close() + + endpoint := utils.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 := utils.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 +} diff --git a/hunliji-api/utils/utils.go b/hunliji-api/utils/utils.go new file mode 100644 index 0000000..906a66a --- /dev/null +++ b/hunliji-api/utils/utils.go @@ -0,0 +1,137 @@ +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +var adminSecret string + +// InitAuth 设置后台 token 签名密钥 +func InitAuth(secret string) { + adminSecret = secret +} + +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 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 EscapeMySQLComment(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `'`, `''`) + return s +} + +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 +} diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go new file mode 100644 index 0000000..ecf7b68 --- /dev/null +++ b/hunliji-api/visit/visit.go @@ -0,0 +1,342 @@ +package visit + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type SiteVisit struct { + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + Fingerprint string `gorm:"column:fingerprint;size:128;not null;index;comment:浏览器指纹" json:"fingerprint"` + ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:本地客户端ID" json:"client_id"` + IP string `gorm:"column:ip;size:64;not null;default:'';index;comment:访客IP" json:"ip"` + Country string `gorm:"column:country;size:64;not null;default:'';comment:国家" json:"country"` + Region string `gorm:"column:region;size:64;not null;default:'';index;comment:省/地区" json:"region"` + City string `gorm:"column:city;size:64;not null;default:'';comment:城市" json:"city"` + ISP string `gorm:"column:isp;size:128;not null;default:'';comment:运营商" json:"isp"` + RawLocation string `gorm:"column:raw_location;size:255;not null;default:'';comment:归属地展示" json:"raw_location"` + UserAgent string `gorm:"column:user_agent;size:512;not null;default:'';comment:UA" json:"user_agent"` + Referer string `gorm:"column:referer;size:512;not null;default:'';comment:来源" json:"referer"` + VisitCount int `gorm:"column:visit_count;not null;default:1;comment:访问次数" json:"visit_count"` + FirstSeenAt time.Time `gorm:"column:first_seen_at;not null;comment:首次访问" json:"first_seen_at"` + LastSeenAt time.Time `gorm:"column:last_seen_at;not null;index;comment:最近访问" json:"last_seen_at"` +} + +func (SiteVisit) TableName() string { return "site_visits" } + +var ( + db *gorm.DB + requireAdmin func(*gin.Context) bool +) + +// Register 由 main 入口调用:注入依赖并挂载路由 +func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { + db = database + requireAdmin = adminGuard + api.POST("/visit", handleTrackVisit) + api.GET("/visit/stats", handleVisitStats) + api.GET("/visit/list", handleVisitList) +} + +type ipGeoResult struct { + Country string + Region string + City string + ISP string + Raw string +} + +type ipCacheEntry struct { + result ipGeoResult + expiresAt time.Time +} + +var ( + ipGeoCache = map[string]ipCacheEntry{} + ipGeoCacheMu sync.Mutex + ipHTTPClient = &http.Client{Timeout: 2 * time.Second} +) + +func lookupIPGeo(ip string) ipGeoResult { + ip = strings.TrimSpace(ip) + if ip == "" || ip == "127.0.0.1" || ip == "::1" { + return ipGeoResult{Raw: "本地"} + } + + ipGeoCacheMu.Lock() + if ent, ok := ipGeoCache[ip]; ok && time.Now().Before(ent.expiresAt) { + ipGeoCacheMu.Unlock() + return ent.result + } + ipGeoCacheMu.Unlock() + + result := fetchIPGeo(ip) + + ipGeoCacheMu.Lock() + ipGeoCache[ip] = ipCacheEntry{result: result, expiresAt: time.Now().Add(30 * time.Minute)} + ipGeoCacheMu.Unlock() + return result +} + +func fetchIPGeo(ip string) ipGeoResult { + body, _ := json.Marshal(map[string]string{"ip": ip}) + req, err := http.NewRequest(http.MethodPost, "http://ip.nailaoyun.cn/api/search-ip", bytes.NewReader(body)) + if err != nil { + return ipGeoResult{} + } + req.Header.Set("Content-Type", "application/json") + resp, err := ipHTTPClient.Do(req) + if err != nil { + log.Printf("IP归属地查询失败 %s: %v", ip, err) + return ipGeoResult{} + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil || len(raw) == 0 { + return ipGeoResult{} + } + return parseIPGeoJSON(raw) +} + +func parseIPGeoJSON(raw []byte) ipGeoResult { + var root map[string]interface{} + if err := json.Unmarshal(raw, &root); err != nil { + return ipGeoResult{} + } + data := root + if nested, ok := root["data"].(map[string]interface{}); ok { + data = nested + } else if nested, ok := root["result"].(map[string]interface{}); ok { + data = nested + } + + pick := func(keys ...string) string { + for _, k := range keys { + if v, ok := data[k]; ok { + switch t := v.(type) { + case string: + if s := strings.TrimSpace(t); s != "" { + return s + } + case float64: + return strconv.FormatInt(int64(t), 10) + } + } + } + return "" + } + + country := pick("country", "Country", "nation", "country_name") + region := pick("province", "region", "Region", "Province", "state") + city := pick("city", "City") + isp := pick("isp", "ISP", "operator", "org") + addr := pick("addr", "address", "location", "Location", "area") + + parts := []string{} + for _, p := range []string{country, region, city} { + if p != "" && (len(parts) == 0 || parts[len(parts)-1] != p) { + parts = append(parts, p) + } + } + rawLoc := strings.Join(parts, " ") + if rawLoc == "" { + rawLoc = addr + } + if rawLoc == "" && isp != "" { + rawLoc = isp + } + + return ipGeoResult{ + Country: country, + Region: region, + City: city, + ISP: isp, + Raw: rawLoc, + } +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} + +func handleTrackVisit(c *gin.Context) { + var body struct { + Fingerprint string `json:"fingerprint"` + ClientID string `json:"client_id"` + UserAgent string `json:"user_agent"` + Referer string `json:"referer"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + fp := strings.TrimSpace(body.Fingerprint) + if fp == "" || len(fp) > 128 { + c.JSON(400, gin.H{"error": "无效的 fingerprint"}) + return + } + clientID := strings.TrimSpace(body.ClientID) + if len(clientID) > 64 { + clientID = clientID[:64] + } + ua := strings.TrimSpace(body.UserAgent) + if ua == "" { + ua = c.GetHeader("User-Agent") + } + ua = truncateRunes(ua, 512) + referer := strings.TrimSpace(body.Referer) + if referer == "" { + referer = c.GetHeader("Referer") + } + referer = truncateRunes(referer, 512) + + ip := c.ClientIP() + geo := lookupIPGeo(ip) + now := time.Now() + + var existing SiteVisit + err := db.Where("fingerprint = ?", fp).Order("last_seen_at desc").First(&existing).Error + if err == nil && now.Sub(existing.LastSeenAt) < 24*time.Hour { + updates := map[string]interface{}{ + "ip": ip, + "country": geo.Country, + "region": geo.Region, + "city": geo.City, + "isp": geo.ISP, + "raw_location": geo.Raw, + "user_agent": ua, + "referer": referer, + "visit_count": existing.VisitCount + 1, + "last_seen_at": now, + } + if clientID != "" { + updates["client_id"] = clientID + } + if err := db.Model(&existing).Updates(updates).Error; err != nil { + c.JSON(500, gin.H{"error": "记录访客失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "ok"}) + return + } + if err != nil && err != gorm.ErrRecordNotFound { + c.JSON(500, gin.H{"error": "记录访客失败"}) + return + } + + row := SiteVisit{ + Fingerprint: fp, + ClientID: clientID, + IP: ip, + Country: geo.Country, + Region: geo.Region, + City: geo.City, + ISP: geo.ISP, + RawLocation: geo.Raw, + UserAgent: ua, + Referer: referer, + VisitCount: 1, + FirstSeenAt: now, + LastSeenAt: now, + } + if err := db.Create(&row).Error; err != nil { + c.JSON(500, gin.H{"error": "记录访客失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "ok"}) +} + +func handleVisitStats(c *gin.Context) { + if !requireAdmin(c) { + return + } + + var uv int64 + db.Model(&SiteVisit{}).Distinct("fingerprint").Count(&uv) + + var pv int64 + db.Model(&SiteVisit{}).Select("COALESCE(SUM(visit_count),0)").Scan(&pv) + + nowDay := time.Now() + startOfDay := time.Date(nowDay.Year(), nowDay.Month(), nowDay.Day(), 0, 0, 0, 0, nowDay.Location()) + var todayUV int64 + db.Model(&SiteVisit{}). + Where("last_seen_at >= ?", startOfDay). + Distinct("fingerprint"). + Count(&todayUV) + + type regionRow struct { + Name string `json:"name"` + Value int64 `json:"value"` + } + var regions []regionRow + db.Model(&SiteVisit{}). + Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value"). + Group("name"). + Order("value desc"). + Limit(30). + Scan(®ions) + if regions == nil { + regions = []regionRow{} + } + + c.JSON(200, gin.H{ + "code": 200, + "data": gin.H{ + "uv": uv, + "pv": pv, + "today_uv": todayUV, + "regions": regions, + }, + }) +} + +func handleVisitList(c *gin.Context) { + if !requireAdmin(c) { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + var total int64 + db.Model(&SiteVisit{}).Count(&total) + + var list []SiteVisit + db.Order("last_seen_at desc"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&list) + if list == nil { + list = []SiteVisit{} + } + + c.JSON(200, gin.H{ + "code": 200, + "data": gin.H{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }, + }) +} diff --git a/hunliji-api/点赞特效.html b/hunliji-api/点赞特效.html new file mode 100644 index 0000000..a2db745 --- /dev/null +++ b/hunliji-api/点赞特效.html @@ -0,0 +1,837 @@ + + +
+ + ++ 访客首次交互后自动播放当前曲目。开启随机后,24 小时内本地缓存同一首。 +
+