Files
hunli/hunliji-api/main.go
2026-08-02 16:11:35 +08:00

862 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}