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 @@