862 lines
26 KiB
Go
862 lines
26 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"mime/multipart"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"hunliji-api/spark"
|
||
|
||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/joho/godotenv"
|
||
"gorm.io/driver/mysql"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type TemplateConfig struct {
|
||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||
ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}';comment:请柬配置JSON" json:"config_data"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
|
||
}
|
||
|
||
func (TemplateConfig) TableName() string { return "template_configs" }
|
||
|
||
type UploadConfig struct {
|
||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||
Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"`
|
||
AccessKeyID string `gorm:"column:access_key_id;size:255;not null;default:'';comment:OSS AccessKeyId" json:"accessKeyId"`
|
||
AccessKeySecret string `gorm:"column:access_key_secret;type:text;comment:OSS AccessKeySecret" json:"-"`
|
||
Bucket string `gorm:"column:bucket;size:255;not null;default:'';comment:OSS Bucket" json:"bucket"`
|
||
Folder string `gorm:"column:folder;size:255;not null;default:'';comment:上传目录前缀" json:"folder"`
|
||
Domain string `gorm:"column:domain;size:255;not null;default:'';comment:访问域名" json:"domain"`
|
||
Region string `gorm:"column:region;size:100;not null;default:'';comment:OSS Region" json:"region"`
|
||
Endpoint string `gorm:"column:endpoint;size:255;not null;default:'';comment:OSS Endpoint" json:"endpoint"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
|
||
}
|
||
|
||
func (UploadConfig) TableName() string { return "upload_configs" }
|
||
|
||
type Rsvp struct {
|
||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`
|
||
GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"`
|
||
Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"`
|
||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"`
|
||
}
|
||
|
||
func (Rsvp) TableName() string { return "rsvps" }
|
||
|
||
type Danmaku struct {
|
||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||
Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"`
|
||
Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"`
|
||
Color string `gorm:"column:color;size: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))
|
||
return db.Exec(sql).Error
|
||
}
|
||
|
||
func migrateSchema(db *gorm.DB) {
|
||
tables := []struct {
|
||
model interface{}
|
||
comment string
|
||
name string
|
||
}{
|
||
{&TemplateConfig{}, "请柬模板配置", "template_configs"},
|
||
{&UploadConfig{}, "上传配置", "upload_configs"},
|
||
{&Rsvp{}, "出席回执", "rsvps"},
|
||
{&Danmaku{}, "祝福弹幕", "danmakus"},
|
||
{&SiteLike{}, "站点点赞", "site_likes"},
|
||
{&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
|
||
}
|
||
for _, t := range tables {
|
||
opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment))
|
||
if err := db.Set("gorm:table_options", opts).AutoMigrate(t.model); err != nil {
|
||
log.Printf("AutoMigrate 失败 %s: %v", t.name, err)
|
||
}
|
||
// 已存在的表不会吃到 table_options,必须再 ALTER 一次写表注释
|
||
if err := setTableComment(db, t.name, t.comment); err != nil {
|
||
log.Printf("设置表注释失败 %s: %v", t.name, err)
|
||
} else {
|
||
log.Printf("表注释已更新 %s => %s", t.name, t.comment)
|
||
}
|
||
}
|
||
// 取消点赞一人一次:去掉 client_id 唯一索引(若存在)
|
||
if db.Migrator().HasIndex(&SiteLike{}, "idx_site_likes_client_id") {
|
||
_ = db.Migrator().DropIndex(&SiteLike{}, "idx_site_likes_client_id")
|
||
}
|
||
}
|
||
|
||
func initDB() {
|
||
dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
|
||
//dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
|
||
var err error
|
||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||
if err != nil {
|
||
log.Fatalf("数据库连接失败: %v", err)
|
||
}
|
||
|
||
migrateSchema(db)
|
||
_ = os.MkdirAll("./uploads", os.ModePerm)
|
||
|
||
fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪")
|
||
}
|
||
|
||
func corsMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT")
|
||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||
if c.Request.Method == "OPTIONS" {
|
||
c.AbortWithStatus(204)
|
||
return
|
||
}
|
||
c.Next()
|
||
}
|
||
}
|
||
|
||
func defaultUploadConfig() UploadConfig {
|
||
return UploadConfig{ID: 1, Provider: "local"}
|
||
}
|
||
|
||
func loadUploadConfig() UploadConfig {
|
||
var config UploadConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
config = defaultUploadConfig()
|
||
_ = db.Create(&config).Error
|
||
}
|
||
if config.Provider == "" {
|
||
config.Provider = "local"
|
||
}
|
||
return config
|
||
}
|
||
|
||
func uploadConfigResponse(config UploadConfig) gin.H {
|
||
return gin.H{
|
||
"provider": config.Provider,
|
||
"accessKeyId": config.AccessKeyID,
|
||
"hasSecret": config.AccessKeySecret != "",
|
||
"bucket": config.Bucket,
|
||
"folder": config.Folder,
|
||
"domain": config.Domain,
|
||
"region": config.Region,
|
||
"endpoint": config.Endpoint,
|
||
"updated_at": config.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func cleanObjectName(name string) string {
|
||
name = filepath.Base(name)
|
||
name = strings.ReplaceAll(name, "\\", "_")
|
||
name = strings.ReplaceAll(name, "/", "_")
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || name == "." {
|
||
return "upload"
|
||
}
|
||
return name
|
||
}
|
||
|
||
func buildObjectKey(folder string, originalName string) string {
|
||
folder = strings.Trim(folder, "/ ")
|
||
name := cleanObjectName(originalName)
|
||
fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name)
|
||
day := time.Now().Format("20060102")
|
||
if folder == "" {
|
||
return day + "/" + fileName
|
||
}
|
||
return folder + "/" + day + "/" + fileName
|
||
}
|
||
|
||
func escapedObjectKey(key string) string {
|
||
parts := strings.Split(key, "/")
|
||
for i, part := range parts {
|
||
parts[i] = url.PathEscape(part)
|
||
}
|
||
return strings.Join(parts, "/")
|
||
}
|
||
|
||
func normalizeEndpoint(endpoint string, region string) string {
|
||
endpoint = strings.TrimSpace(endpoint)
|
||
region = strings.TrimSpace(region)
|
||
if endpoint == "" && region != "" {
|
||
endpoint = "https://oss-" + region + ".aliyuncs.com"
|
||
}
|
||
if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
|
||
endpoint = "https://" + endpoint
|
||
}
|
||
return endpoint
|
||
}
|
||
|
||
func objectURL(config UploadConfig, objectKey string) string {
|
||
escapedKey := escapedObjectKey(objectKey)
|
||
if strings.TrimSpace(config.Domain) != "" {
|
||
domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/")
|
||
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
|
||
domain = "https://" + domain
|
||
}
|
||
return domain + "/" + escapedKey
|
||
}
|
||
|
||
endpoint := strings.TrimRight(normalizeEndpoint(config.Endpoint, config.Region), "/")
|
||
u, err := url.Parse(endpoint)
|
||
if err == nil && u.Host != "" {
|
||
return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey
|
||
}
|
||
if endpoint == "" {
|
||
return escapedKey
|
||
}
|
||
return endpoint + "/" + escapedKey
|
||
}
|
||
|
||
func canUseOSS(config UploadConfig) bool {
|
||
return config.Provider == "oss" &&
|
||
strings.TrimSpace(config.AccessKeyID) != "" &&
|
||
strings.TrimSpace(config.AccessKeySecret) != "" &&
|
||
strings.TrimSpace(config.Bucket) != "" &&
|
||
(strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "")
|
||
}
|
||
|
||
func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
|
||
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), cleanObjectName(file.Filename))
|
||
savePath := filepath.Join("uploads", filename)
|
||
|
||
if err := c.SaveUploadedFile(file, savePath); err != nil {
|
||
c.JSON(500, gin.H{"error": "文件保存至服务器失败"})
|
||
return
|
||
}
|
||
|
||
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
|
||
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
|
||
}
|
||
|
||
func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config UploadConfig) bool {
|
||
file, err := fileHeader.Open()
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": "文件打开失败"})
|
||
return false
|
||
}
|
||
defer file.Close()
|
||
|
||
endpoint := normalizeEndpoint(config.Endpoint, config.Region)
|
||
provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)
|
||
ossConfig := oss.LoadDefaultConfig().
|
||
WithCredentialsProvider(provider).
|
||
WithRegion(config.Region).
|
||
WithEndpoint(endpoint)
|
||
client := oss.NewClient(ossConfig)
|
||
|
||
objectKey := buildObjectKey(config.Folder, fileHeader.Filename)
|
||
_, err = client.PutObject(context.Background(), &oss.PutObjectRequest{
|
||
Bucket: oss.Ptr(config.Bucket),
|
||
Key: oss.Ptr(objectKey),
|
||
Acl: oss.ObjectACLPublicRead,
|
||
Body: file,
|
||
})
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
|
||
return false
|
||
}
|
||
|
||
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
|
||
return true
|
||
}
|
||
|
||
func collectAlbumImages(configData string) []string {
|
||
var raw map[string]interface{}
|
||
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
|
||
return []string{}
|
||
}
|
||
|
||
seen := map[string]bool{}
|
||
images := []string{}
|
||
add := func(value interface{}) {
|
||
src, ok := value.(string)
|
||
if !ok {
|
||
return
|
||
}
|
||
src = strings.TrimSpace(src)
|
||
if src == "" || seen[src] {
|
||
return
|
||
}
|
||
seen[src] = true
|
||
images = append(images, src)
|
||
}
|
||
|
||
add(raw["heroImg"])
|
||
add(raw["endImg"])
|
||
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
||
for _, item := range pages {
|
||
page, ok := item.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
add(page["img1"])
|
||
add(page["img2"])
|
||
if pageImages, ok := page["images"].([]interface{}); ok {
|
||
for _, src := range pageImages {
|
||
add(src)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return images
|
||
}
|
||
|
||
func main() {
|
||
_ = godotenv.Load()
|
||
adminAccount = getEnv("ADMIN_ACCOUNT", "admin")
|
||
adminPassword = getEnv("ADMIN_PASSWORD", "admin123")
|
||
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
|
||
sparkClient = spark.NewFromEnv()
|
||
|
||
initDB()
|
||
r := gin.Default()
|
||
r.Use(corsMiddleware())
|
||
r.Static("/uploads", "./uploads")
|
||
|
||
api := r.Group("/api")
|
||
{
|
||
api.POST("/admin/login", func(c *gin.Context) {
|
||
var body struct {
|
||
Account string `json:"account"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
if body.Account != adminAccount || body.Password != adminPassword {
|
||
c.JSON(401, gin.H{"error": "账号或密码错误"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"token": signToken(body.Account),
|
||
"user": gin.H{"account": body.Account},
|
||
})
|
||
})
|
||
|
||
api.GET("/config", func(c *gin.Context) {
|
||
var config TemplateConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)})
|
||
return
|
||
}
|
||
raw := strings.TrimSpace(config.ConfigData)
|
||
if raw == "" {
|
||
raw = "{}"
|
||
}
|
||
// ConfigData 在库里是 JSON 文本;用 RawMessage 嵌入,避免再被编码成带转义的字符串
|
||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)})
|
||
})
|
||
|
||
api.GET("/album", func(c *gin.Context) {
|
||
var config TemplateConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
c.JSON(200, gin.H{"code": 200, "data": []string{}})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
|
||
})
|
||
|
||
api.POST("/config", func(c *gin.Context) {
|
||
if !requireAdmin(c) {
|
||
return
|
||
}
|
||
var rawBody interface{}
|
||
if err := c.BindJSON(&rawBody); err != nil {
|
||
c.JSON(400, gin.H{"error": "无效的JSON数据"})
|
||
return
|
||
}
|
||
jsonBytes, _ := json.Marshal(rawBody)
|
||
|
||
var config TemplateConfig
|
||
db.FirstOrCreate(&config, 1)
|
||
config.ConfigData = string(jsonBytes)
|
||
|
||
if err := db.Save(&config).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
|
||
})
|
||
|
||
api.GET("/upload/config", func(c *gin.Context) {
|
||
if !requireAdmin(c) {
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
|
||
})
|
||
|
||
api.POST("/upload/config", func(c *gin.Context) {
|
||
if !requireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Provider string `json:"provider"`
|
||
AccessKeyID string `json:"accessKeyId"`
|
||
AccessKeySecret string `json:"accessKeySecret"`
|
||
Bucket string `json:"bucket"`
|
||
Folder string `json:"folder"`
|
||
Domain string `json:"domain"`
|
||
Region string `json:"region"`
|
||
Endpoint string `json:"endpoint"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
|
||
config := loadUploadConfig()
|
||
provider := strings.TrimSpace(body.Provider)
|
||
if provider != "oss" {
|
||
provider = "local"
|
||
}
|
||
config.Provider = provider
|
||
config.AccessKeyID = strings.TrimSpace(body.AccessKeyID)
|
||
if strings.TrimSpace(body.AccessKeySecret) != "" {
|
||
config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret)
|
||
}
|
||
config.Bucket = strings.TrimSpace(body.Bucket)
|
||
config.Folder = strings.Trim(body.Folder, "/ ")
|
||
config.Domain = strings.TrimSpace(body.Domain)
|
||
config.Region = strings.TrimSpace(body.Region)
|
||
config.Endpoint = strings.TrimSpace(body.Endpoint)
|
||
|
||
if err := db.Save(&config).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "上传配置保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"})
|
||
})
|
||
|
||
api.POST("/ai/blessing", func(c *gin.Context) {
|
||
if sparkClient == nil || !sparkClient.Enabled() {
|
||
c.JSON(503, gin.H{"error": "AI 未配置"})
|
||
return
|
||
}
|
||
var body struct {
|
||
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": "点赞成功"})
|
||
})
|
||
}
|
||
|
||
fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动")
|
||
r.Run(":15201")
|
||
}
|