diff --git a/hunliji-api/.env b/hunliji-api/.env index 82ba197..fb95160 100644 --- a/hunliji-api/.env +++ b/hunliji-api/.env @@ -1,3 +1,7 @@ ADMIN_ACCOUNT=liqi ADMIN_PASSWORD=qiqi991012 -ADMIN_SECRET=wedding-admin-secret-2026 \ No newline at end of file +ADMIN_SECRET=wedding-admin-secret-2026 + +SPARK_API_PASSWORD=nZurRXizItjgjRmPkcRu:MiJrBykLPPEqNDHsGhCl +SPARK_API_URL=https://spark-api-open.xf-yun.com/v1/chat/completions +SPARK_MODEL=lite \ No newline at end of file diff --git a/hunliji-api/go.mod b/hunliji-api/go.mod index a9d1ba3..65bc7f7 100644 --- a/hunliji-api/go.mod +++ b/hunliji-api/go.mod @@ -25,6 +25,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/hunliji-api/go.sum b/hunliji-api/go.sum index 19e2ffd..f593207 100644 --- a/hunliji-api/go.sum +++ b/hunliji-api/go.sum @@ -40,6 +40,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= diff --git a/hunliji-api/hunliji-api.exe b/hunliji-api/hunliji-api.exe index 6361d02..e9b9b1e 100644 Binary files a/hunliji-api/hunliji-api.exe and b/hunliji-api/hunliji-api.exe differ diff --git a/hunliji-api/main.go b/hunliji-api/main.go index d544065..cb24c20 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -15,41 +15,95 @@ import ( "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" json:"id"` - ConfigData string `gorm:"type:json;column:config_data" json:"config_data"` - UpdatedAt time.Time `json:"updated_at"` + 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" 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"` + 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" 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"` + 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" } + +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") @@ -57,6 +111,18 @@ var ( adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") ) +func moderateOrPass(name, content string) (bool, string) { + if sparkClient == nil || !sparkClient.Enabled() { + return true, "" + } + ok, reason, err := sparkClient.ModerateText(name, content) + if err != nil { + log.Printf("AI 审核失败,放行: %v", err) + return true, "" + } + return ok, reason +} + func getEnv(k, def string) string { if v := os.Getenv(k); v != "" { return v @@ -110,6 +176,48 @@ func requireAdmin(c *gin.Context) bool { 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"}, + } + 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" @@ -119,7 +227,7 @@ func initDB() { log.Fatalf("数据库连接失败: %v", err) } - _ = db.AutoMigrate(&TemplateConfig{}, &UploadConfig{}, &Rsvp{}) + migrateSchema(db) _ = os.MkdirAll("./uploads", os.ModePerm) fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪") @@ -326,6 +434,12 @@ func collectAlbumImages(configData string) []string { } 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()) @@ -356,10 +470,15 @@ func main() { 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": "{}"}) + c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)}) return } - c.JSON(200, gin.H{"code": 200, "data": config.ConfigData}) + 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) { @@ -442,6 +561,31 @@ func main() { 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 { @@ -449,11 +593,128 @@ func main() { return } + wishes := strings.TrimSpace(rsvp.Wishes) + if wishes != "" { + ok, reason := moderateOrPass(rsvp.Name, wishes) + 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 } - c.JSON(200, gin.H{"code": 200, "msg": "回执提交成功"}) + + 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) { + var list []Danmaku + db.Where("status = ?", "approved").Order("created_at 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) + 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) { @@ -482,6 +743,36 @@ func main() { 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 启动") diff --git a/hunliji-api/spark/blessing.go b/hunliji-api/spark/blessing.go new file mode 100644 index 0000000..10f6ae7 --- /dev/null +++ b/hunliji-api/spark/blessing.go @@ -0,0 +1,50 @@ +package spark + +import ( + "fmt" + "strings" +) + +var BlessingStyles = map[string]string{ + "classic": "古风典雅", + "modern": "现代文风", + "funny": "搞怪文风", + "roast": "损友文风", +} + +func NormalizeStyle(style string) (string, bool) { + s := strings.TrimSpace(style) + _, ok := BlessingStyles[s] + return s, ok +} + +func (c *Client) GenerateBlessing(name, style string) (string, error) { + styleKey, ok := NormalizeStyle(style) + if !ok { + return "", fmt.Errorf("无效的祝福风格") + } + label := BlessingStyles[styleKey] + guest := strings.TrimSpace(name) + guestHint := "宾客未署名" + if guest != "" { + guestHint = "宾客姓名:" + guest + } + + styleGuide := map[string]string{ + "classic": "用文言雅致、含蓄温润的古风表达,可适度用典故意象,忌生僻堆砌。", + "modern": "用简洁真诚的现代汉语,自然得体,适合婚礼现场朗读。", + "funny": "轻松幽默、俏皮可爱,但保持祝福善意,不低俗。", + "roast": "损友互怼口吻,先轻度调侃再真心祝福,尺度友好不人身攻击。", + } + + sys := "你是婚礼祝福文案助手。只输出一条中文祝福正文,不要标题、不要解释、不要引号包裹。" + user := fmt.Sprintf(`请写一条婚礼祝福。 +风格:%s(%s) +%s +要求:30到60个汉字;积极祝福新人;不要出现违法、色情、歧视或辱骂内容。`, label, styleGuide[styleKey], guestHint) + + return c.Chat([]Message{ + {Role: "system", Content: sys}, + {Role: "user", Content: user}, + }) +} diff --git a/hunliji-api/spark/client.go b/hunliji-api/spark/client.go new file mode 100644 index 0000000..7a56a78 --- /dev/null +++ b/hunliji-api/spark/client.go @@ -0,0 +1,112 @@ +package spark + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` +} + +type chatResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Choices []struct { + Message Message `json:"message"` + } `json:"choices"` +} + +type Client struct { + APIURL string + Password string + Model string + HTTP *http.Client +} + +func NewFromEnv() *Client { + return &Client{ + APIURL: strings.TrimSpace(envOr("SPARK_API_URL", "https://spark-api-open.xf-yun.com/v1/chat/completions")), + Password: strings.TrimSpace(os.Getenv("SPARK_API_PASSWORD")), + Model: strings.TrimSpace(envOr("SPARK_MODEL", "lite")), + HTTP: &http.Client{Timeout: 45 * time.Second}, + } +} + +func (c *Client) Enabled() bool { + return c != nil && c.Password != "" +} + +func (c *Client) Chat(messages []Message) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("AI 未配置") + } + body, _ := json.Marshal(chatRequest{ + Model: c.Model, + Messages: messages, + Stream: false, + Temperature: 0.8, + MaxTokens: 512, + }) + req, err := http.NewRequest(http.MethodPost, c.APIURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.Password) + + res, err := c.HTTP.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + raw, _ := io.ReadAll(res.Body) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", fmt.Errorf("星火接口 HTTP %d: %s", res.StatusCode, truncate(string(raw), 200)) + } + var parsed chatResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("解析星火响应失败: %w", err) + } + if parsed.Code != 0 { + return "", fmt.Errorf("星火返回错误 %d: %s", parsed.Code, parsed.Message) + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("星火未返回内容") + } + text := strings.TrimSpace(parsed.Choices[0].Message.Content) + if text == "" { + return "", fmt.Errorf("星火返回空内容") + } + return text, nil +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/hunliji-api/spark/moderate.go b/hunliji-api/spark/moderate.go new file mode 100644 index 0000000..3dd9c29 --- /dev/null +++ b/hunliji-api/spark/moderate.go @@ -0,0 +1,58 @@ +package spark + +import ( + "encoding/json" + "fmt" + "log" + "strings" +) + +type ModerateResult struct { + OK bool `json:"ok"` + Reason string `json:"reason"` +} + +// ModerateText 审核姓名与内容。AI 未配置时放行。 +func (c *Client) ModerateText(name, content string) (ok bool, reason string, err error) { + if !c.Enabled() { + log.Println("spark moderate skipped: SPARK_API_PASSWORD empty") + return true, "", nil + } + sys := `你是婚礼请柬内容安全审核助手。判断姓名与祝福/弹幕是否包含违法违规、色情、政治敏感、人身攻击、脏话辱骂、歧视仇恨等内容。 +只输出一个 JSON 对象,不要 markdown,不要其它文字,格式严格为: +{"ok":true,"reason":""} +或 +{"ok":false,"reason":"简短中文原因"} +ok=true 表示可公开展示;ok=false 表示应拦截。` + user := fmt.Sprintf("姓名:%s\n内容:%s", strings.TrimSpace(name), strings.TrimSpace(content)) + raw, err := c.Chat([]Message{ + {Role: "system", Content: sys}, + {Role: "user", Content: user}, + }) + if err != nil { + return false, "", err + } + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var res ModerateResult + if err := json.Unmarshal([]byte(raw), &res); err != nil { + // 尝试截取第一个 JSON 对象 + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start >= 0 && end > start { + if err2 := json.Unmarshal([]byte(raw[start:end+1]), &res); err2 != nil { + return false, "", fmt.Errorf("解析审核结果失败: %v; raw=%s", err, truncate(raw, 120)) + } + } else { + return false, "", fmt.Errorf("解析审核结果失败: %v", err) + } + } + if !res.OK && strings.TrimSpace(res.Reason) == "" { + res.Reason = "内容未通过审核" + } + return res.OK, strings.TrimSpace(res.Reason), nil +} diff --git a/hunliji-api/打包 b/hunliji-api/打包 new file mode 100644 index 0000000..9d5ef40 --- /dev/null +++ b/hunliji-api/打包 @@ -0,0 +1,5 @@ +$env:GOOS="linux" + +$env:GOARCH="amd64" + +go build -o hl-api main.go \ No newline at end of file diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index 89ba0cf..96d3061 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -38,7 +38,9 @@ service.interceptors.response.use( localStorage.removeItem('admin_token') localStorage.removeItem('admin_user') } - return Promise.reject(error) + const body = error?.response?.data + const msg = body?.error || body?.msg || error.message || '请求失败' + return Promise.reject(new Error(msg)) } ) @@ -78,6 +80,34 @@ export function getRsvpList() { return service.get('/rsvp/list') } +export function getDanmaku() { + return service.get('/danmaku') +} + +export function submitDanmaku(form) { + return service.post('/danmaku', form) +} + +export function generateAiBlessing({ name, style }) { + return service.post('/ai/blessing', { name, style }, { timeout: 45000 }) +} + +export function getDanmakuList(params) { + return service.get('/danmaku/list', { params }) +} + +export function updateDanmakuStatus(id, status) { + return service.post(`/danmaku/${id}/status`, { status }) +} + +export function getLikeStatus(clientId) { + return service.get('/like', { params: { client_id: clientId } }) +} + +export function submitLike(clientId) { + return service.post('/like', { client_id: clientId }) +} + export function adminLogin(credentials) { return service.post('/admin/login', credentials) } diff --git a/vite-tailwindcss/src/components/DanmakuLayer.vue b/vite-tailwindcss/src/components/DanmakuLayer.vue new file mode 100644 index 0000000..ad2435a --- /dev/null +++ b/vite-tailwindcss/src/components/DanmakuLayer.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/vite-tailwindcss/src/components/LikeBurst.vue b/vite-tailwindcss/src/components/LikeBurst.vue new file mode 100644 index 0000000..23e1e49 --- /dev/null +++ b/vite-tailwindcss/src/components/LikeBurst.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/vite-tailwindcss/src/utils/clientId.js b/vite-tailwindcss/src/utils/clientId.js new file mode 100644 index 0000000..7ebaeff --- /dev/null +++ b/vite-tailwindcss/src/utils/clientId.js @@ -0,0 +1,19 @@ +const KEY = 'wedding_client_id_v1' + +function uuid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID() + return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` +} + +export function getClientId() { + try { + let id = localStorage.getItem(KEY) + if (!id) { + id = uuid() + localStorage.setItem(KEY, id) + } + return id + } catch { + return uuid() + } +} diff --git a/vite-tailwindcss/src/utils/danmakuColors.js b/vite-tailwindcss/src/utils/danmakuColors.js new file mode 100644 index 0000000..a4f342b --- /dev/null +++ b/vite-tailwindcss/src/utils/danmakuColors.js @@ -0,0 +1,48 @@ +export const DEFAULT_DANMAKU_COLOR = 'champagne' + +/** @type {{ key: string, hex: string, label: string }[]} */ +export const DANMAKU_COLORS = [ + { key: 'champagne', hex: '#a88c6b', label: '香槟金' }, + { key: 'blush', hex: '#e8b4b8', label: '浅粉' }, + { key: 'apricot', hex: '#e6b89c', label: '杏桃' }, + { key: 'gold', hex: '#d4a574', label: '暖金' }, + { key: 'lilac', hex: '#b5a4c9', label: '淡紫' }, + { key: 'sky', hex: '#7c9eb2', label: '雾蓝' }, + { key: 'mauve', hex: '#9b7e9a', label: '藕紫' }, + { key: 'slate', hex: '#64748b', label: '灰蓝' }, + { key: 'ink', hex: '#5c4a35', label: '墨褐' }, +] + +const HEX_BY_KEY = Object.fromEntries(DANMAKU_COLORS.map((c) => [c.key, c.hex])) + +function hexToRgb(hex) { + const h = hex.replace('#', '') + const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16) + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 } +} + +export function resolveDanmakuColor(key) { + if (key === 'rose' || key === 'sage') return HEX_BY_KEY[DEFAULT_DANMAKU_COLOR] + return HEX_BY_KEY[key] || HEX_BY_KEY[DEFAULT_DANMAKU_COLOR] +} + +export function resolveDanmakuTint(key) { + const hex = resolveDanmakuColor(key) + const { r, g, b } = hexToRgb(hex) + return { + background: `rgba(${r}, ${g}, ${b}, 0.18)`, + borderColor: `rgba(${r}, ${g}, ${b}, 0.42)`, + } +} + +export function normalizeDanmakuColor(key) { + if (key === 'rose' || key === 'sage') return DEFAULT_DANMAKU_COLOR + return HEX_BY_KEY[key] ? key : DEFAULT_DANMAKU_COLOR +} + +export const BLESSING_STYLES = [ + { key: 'classic', label: '古风典雅' }, + { key: 'modern', label: '现代文风' }, + { key: 'funny', label: '搞怪文风' }, + { key: 'roast', label: '损友文风' }, +] diff --git a/vite-tailwindcss/src/utils/musicPick.js b/vite-tailwindcss/src/utils/musicPick.js new file mode 100644 index 0000000..2aeaa08 --- /dev/null +++ b/vite-tailwindcss/src/utils/musicPick.js @@ -0,0 +1,44 @@ +const STORAGE_KEY = 'wedding_music_pick_v1' +const DAY_MS = 24 * 60 * 60 * 1000 + +function readCache() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if (!parsed?.url || !parsed?.ts) return null + return parsed + } catch { + return null + } +} + +export function rememberMusicPick(url) { + if (!url) return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, ts: Date.now() })) + } catch { /* ignore quota */ } +} + +/** + * Resolve which track to play. + * When random is enabled, reuse local cache within 24h if still in list; otherwise pick randomly. + */ +export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled) { + const list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : [] + if (!list.length) return '' + + if (!musicRandomEnabled) { + if (activeMusicUrl && list.some((t) => t.url === activeMusicUrl)) return activeMusicUrl + return list[0].url + } + + const cache = readCache() + if (cache && Date.now() - cache.ts < DAY_MS && list.some((t) => t.url === cache.url)) { + return cache.url + } + + const picked = list[Math.floor(Math.random() * list.length)].url + rememberMusicPick(picked) + return picked +} diff --git a/vite-tailwindcss/src/utils/relativeTime.js b/vite-tailwindcss/src/utils/relativeTime.js new file mode 100644 index 0000000..e9d3be3 --- /dev/null +++ b/vite-tailwindcss/src/utils/relativeTime.js @@ -0,0 +1,52 @@ +const WEEKDAY = ['日', '一', '二', '三', '四', '五', '六'] + +function startOfWeekMonday(d) { + const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()) + const day = x.getDay() // 0 Sun .. 6 Sat + const diff = day === 0 ? 6 : day - 1 + x.setDate(x.getDate() - diff) + x.setHours(0, 0, 0, 0) + return x +} + +/** + * Chinese relative time for danmaku: + * 刚刚 | x分钟前 | x小时前 | x天前 | 周X | 上周X | x周前 | X月X日 | X年X月X日 + */ +export function formatRelativeTime(input, now = new Date()) { + const date = input instanceof Date ? input : new Date(input) + if (Number.isNaN(date.getTime())) return '' + + const diffMs = now.getTime() - date.getTime() + if (diffMs < 0) return '刚刚' + + const minute = 60 * 1000 + const hour = 60 * minute + const day = 24 * hour + const week = 7 * day + + if (diffMs < minute) return '刚刚' + if (diffMs < hour) return `${Math.floor(diffMs / minute)}分钟前` + if (diffMs < day) return `${Math.floor(diffMs / hour)}小时前` + if (diffMs < week) return `${Math.floor(diffMs / day)}天前` + + const thisWeekStart = startOfWeekMonday(now) + const lastWeekStart = new Date(thisWeekStart) + lastWeekStart.setDate(lastWeekStart.getDate() - 7) + const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + + if (dateDay >= thisWeekStart) { + return `周${WEEKDAY[date.getDay()]}` + } + if (dateDay >= lastWeekStart) { + return `上周${WEEKDAY[date.getDay()]}` + } + + const weeksAgo = Math.floor(diffMs / week) + if (weeksAgo < 8) return `${weeksAgo}周前` + + if (date.getFullYear() === now.getFullYear()) { + return `${date.getMonth() + 1}月${date.getDate()}日` + } + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` +} diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index cd99a6c..357936c 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -151,6 +151,16 @@ +
+ + +
关闭后前台不展示弹幕,也不显示送祝福入口。
+
+ + +
开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。
+
+
@@ -161,6 +171,10 @@
上传多首背景音乐(支持 mp3 等格式),访客首次交互后自动播放当前曲目,可在前台随时切换或暂停。
+ + +
开启后访客进入时随机选曲;本地缓存 24 小时内再次进入保持同一首。
+
@@ -386,19 +400,65 @@ + + + +
+ + 全部 + 待审核 + 已通过 + 已拒绝 + + 刷新 +
+ + + +