604 lines
16 KiB
Go
604 lines
16 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"mime/multipart"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"hunliji-api/configsection"
|
|
"hunliji-api/models"
|
|
"hunliji-api/spark"
|
|
"hunliji-api/utils"
|
|
"hunliji-api/visit"
|
|
|
|
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
|
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Deps struct {
|
|
DB *gorm.DB
|
|
Spark *spark.Client
|
|
AdminAccount string
|
|
AdminPassword string
|
|
}
|
|
|
|
var (
|
|
db *gorm.DB
|
|
sparkClient *spark.Client
|
|
adminAccount string
|
|
adminPassword string
|
|
)
|
|
|
|
var danmakuColorWhitelist = map[string]struct{}{
|
|
"champagne": {}, "blush": {}, "apricot": {}, "gold": {}, "lilac": {},
|
|
"sky": {}, "mauve": {}, "slate": {}, "ink": {},
|
|
"gradSunset": {}, "gradChampagne": {}, "gradBlush": {},
|
|
"gradOcean": {}, "gradAurora": {}, "gradEmber": {},
|
|
}
|
|
|
|
// Register 挂载静态资源、中间件与全部 /api 路由
|
|
func Register(r *gin.Engine, deps Deps) {
|
|
db = deps.DB
|
|
sparkClient = deps.Spark
|
|
adminAccount = deps.AdminAccount
|
|
adminPassword = deps.AdminPassword
|
|
|
|
r.Use(utils.CORSMiddleware())
|
|
r.Static("/uploads", "./uploads")
|
|
|
|
api := r.Group("/api")
|
|
registerAPI(api)
|
|
}
|
|
|
|
func registerAPI(api *gin.RouterGroup) {
|
|
api.POST("/admin/login", handleAdminLogin)
|
|
api.GET("/config", handleGetConfig)
|
|
api.POST("/config", handleSaveConfig)
|
|
api.GET("/album", handleAlbum)
|
|
api.GET("/upload/config", handleGetUploadConfig)
|
|
api.POST("/upload/config", handleSaveUploadConfig)
|
|
api.POST("/upload", handleUpload)
|
|
api.POST("/ai/blessing", handleAIBlessing)
|
|
api.POST("/rsvp", handleCreateRsvp)
|
|
api.GET("/rsvp/list", handleRsvpList)
|
|
api.GET("/danmaku", handlePublicDanmaku)
|
|
api.POST("/danmaku", handleCreateDanmaku)
|
|
api.GET("/danmaku/list", handleDanmakuList)
|
|
api.POST("/danmaku/:id/status", handleDanmakuStatus)
|
|
api.GET("/like", handleLikeCount)
|
|
api.POST("/like", handleLike)
|
|
|
|
visit.Register(api, db, utils.RequireAdmin)
|
|
configsection.Register(api, db, utils.RequireAdmin)
|
|
}
|
|
|
|
func handleAdminLogin(c *gin.Context) {
|
|
var body struct {
|
|
Account string `json:"account"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "参数错误"})
|
|
return
|
|
}
|
|
if body.Account != adminAccount || body.Password != adminPassword {
|
|
c.JSON(401, gin.H{"error": "账号或密码错误"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{
|
|
"code": 200,
|
|
"token": utils.SignToken(body.Account),
|
|
"user": gin.H{"account": body.Account},
|
|
})
|
|
}
|
|
|
|
func handleGetConfig(c *gin.Context) {
|
|
var config models.TemplateConfig
|
|
if err := db.First(&config, 1).Error; err != nil {
|
|
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)})
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(config.ConfigData)
|
|
if raw == "" {
|
|
raw = "{}"
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)})
|
|
}
|
|
|
|
func handleSaveConfig(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
var rawBody interface{}
|
|
if err := c.BindJSON(&rawBody); err != nil {
|
|
c.JSON(400, gin.H{"error": "无效的JSON数据"})
|
|
return
|
|
}
|
|
jsonBytes, _ := json.Marshal(rawBody)
|
|
var config models.TemplateConfig
|
|
db.FirstOrCreate(&config, 1)
|
|
config.ConfigData = string(jsonBytes)
|
|
if err := db.Save(&config).Error; err != nil {
|
|
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
|
|
}
|
|
|
|
func handleAlbum(c *gin.Context) {
|
|
var config models.TemplateConfig
|
|
if err := db.First(&config, 1).Error; err != nil {
|
|
c.JSON(200, gin.H{"code": 200, "data": []string{}})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
|
|
}
|
|
|
|
func handleGetUploadConfig(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
|
|
}
|
|
|
|
func handleSaveUploadConfig(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
var body struct {
|
|
Provider string `json:"provider"`
|
|
AccessKeyID string `json:"accessKeyId"`
|
|
AccessKeySecret string `json:"accessKeySecret"`
|
|
Bucket string `json:"bucket"`
|
|
Folder string `json:"folder"`
|
|
Domain string `json:"domain"`
|
|
Region string `json:"region"`
|
|
Endpoint string `json:"endpoint"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "参数错误"})
|
|
return
|
|
}
|
|
config := loadUploadConfig()
|
|
provider := strings.TrimSpace(body.Provider)
|
|
if provider != "oss" {
|
|
provider = "local"
|
|
}
|
|
config.Provider = provider
|
|
config.AccessKeyID = strings.TrimSpace(body.AccessKeyID)
|
|
if strings.TrimSpace(body.AccessKeySecret) != "" {
|
|
config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret)
|
|
}
|
|
config.Bucket = strings.TrimSpace(body.Bucket)
|
|
config.Folder = strings.Trim(body.Folder, "/ ")
|
|
config.Domain = strings.TrimSpace(body.Domain)
|
|
config.Region = strings.TrimSpace(body.Region)
|
|
config.Endpoint = strings.TrimSpace(body.Endpoint)
|
|
if err := db.Save(&config).Error; err != nil {
|
|
c.JSON(500, gin.H{"error": "上传配置保存失败"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"})
|
|
}
|
|
|
|
func handleUpload(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(400, gin.H{"error": "未获取到上传文件"})
|
|
return
|
|
}
|
|
config := loadUploadConfig()
|
|
if canUseOSS(config) {
|
|
saveOSSUpload(c, file, config)
|
|
return
|
|
}
|
|
saveLocalUpload(c, file)
|
|
}
|
|
|
|
func handleAIBlessing(c *gin.Context) {
|
|
if sparkClient == nil || !sparkClient.Enabled() {
|
|
c.JSON(503, gin.H{"error": "AI 未配置"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Style string `json:"style"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "参数错误"})
|
|
return
|
|
}
|
|
if _, ok := spark.NormalizeStyle(body.Style); !ok {
|
|
c.JSON(400, gin.H{"error": "请选择祝福风格"})
|
|
return
|
|
}
|
|
text, err := sparkClient.GenerateBlessing(body.Style)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": gin.H{"text": text}})
|
|
}
|
|
|
|
func handleCreateRsvp(c *gin.Context) {
|
|
var rsvp models.Rsvp
|
|
if err := c.ShouldBindJSON(&rsvp); err != nil {
|
|
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
|
|
return
|
|
}
|
|
wishes := strings.TrimSpace(rsvp.Wishes)
|
|
if wishes != "" {
|
|
ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
|
|
if !ok {
|
|
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
|
return
|
|
}
|
|
}
|
|
if err := db.Create(&rsvp).Error; err != nil {
|
|
c.JSON(500, gin.H{"error": "出席信息写入数据库失败"})
|
|
return
|
|
}
|
|
var danmaku *models.Danmaku
|
|
if wishes != "" {
|
|
rid := rsvp.ID
|
|
item := models.Danmaku{
|
|
Name: rsvp.Name, Content: wishes, Color: "champagne",
|
|
Source: "rsvp", RsvpID: &rid, Status: "approved",
|
|
}
|
|
if err := db.Create(&item).Error; err == nil {
|
|
danmaku = &item
|
|
}
|
|
}
|
|
resp := gin.H{"code": 200, "msg": "回执提交成功"}
|
|
if danmaku != nil {
|
|
resp["danmaku"] = danmaku
|
|
}
|
|
c.JSON(200, resp)
|
|
}
|
|
|
|
func handleRsvpList(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
var list []models.Rsvp
|
|
db.Order("created_at desc").Find(&list)
|
|
c.JSON(200, gin.H{"code": 200, "data": list})
|
|
}
|
|
|
|
func handlePublicDanmaku(c *gin.Context) {
|
|
q := db.Where("status = ?", "approved")
|
|
if after := strings.TrimSpace(c.Query("after_id")); after != "" {
|
|
var afterID uint64
|
|
if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 {
|
|
q = q.Where("id > ?", afterID)
|
|
}
|
|
}
|
|
var list []models.Danmaku
|
|
q.Order("id asc").Find(&list)
|
|
c.JSON(200, gin.H{"code": 200, "data": list})
|
|
}
|
|
|
|
func handleCreateDanmaku(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
Color string `json:"color"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
content := strings.TrimSpace(body.Content)
|
|
if name == "" || content == "" {
|
|
c.JSON(400, gin.H{"error": "请填写姓名和祝福"})
|
|
return
|
|
}
|
|
if len([]rune(name)) > 50 {
|
|
c.JSON(400, gin.H{"error": "姓名过长"})
|
|
return
|
|
}
|
|
ok, reason := moderateOrPass(name, content, "danmaku")
|
|
if !ok {
|
|
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
|
return
|
|
}
|
|
item := models.Danmaku{
|
|
Name: name, Content: content, Color: normalizeDanmakuColor(body.Color),
|
|
Source: "danmaku", Status: "approved",
|
|
}
|
|
if err := db.Create(&item).Error; err != nil {
|
|
c.JSON(500, gin.H{"error": "祝福提交失败"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "data": item, "msg": "发送成功"})
|
|
}
|
|
|
|
func handleDanmakuList(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
q := db.Order("created_at desc")
|
|
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
|
q = q.Where("status = ?", status)
|
|
}
|
|
var list []models.Danmaku
|
|
q.Find(&list)
|
|
c.JSON(200, gin.H{"code": 200, "data": list})
|
|
}
|
|
|
|
func handleDanmakuStatus(c *gin.Context) {
|
|
if !utils.RequireAdmin(c) {
|
|
return
|
|
}
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "参数错误"})
|
|
return
|
|
}
|
|
status := strings.TrimSpace(body.Status)
|
|
if status != "approved" && status != "rejected" && status != "pending" {
|
|
c.JSON(400, gin.H{"error": "无效的状态"})
|
|
return
|
|
}
|
|
id := c.Param("id")
|
|
res := db.Model(&models.Danmaku{}).Where("id = ?", id).Update("status", status)
|
|
if res.Error != nil {
|
|
c.JSON(500, gin.H{"error": "更新失败"})
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
c.JSON(404, gin.H{"error": "记录不存在"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "msg": "状态已更新"})
|
|
}
|
|
|
|
func handleLikeCount(c *gin.Context) {
|
|
var count int64
|
|
db.Model(&models.SiteLike{}).Count(&count)
|
|
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}})
|
|
}
|
|
|
|
func handleLike(c *gin.Context) {
|
|
var body struct {
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "参数错误"})
|
|
return
|
|
}
|
|
clientID := strings.TrimSpace(body.ClientID)
|
|
if clientID == "" || len(clientID) > 64 {
|
|
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
|
return
|
|
}
|
|
if err := db.Create(&models.SiteLike{ClientID: clientID}).Error; err != nil {
|
|
c.JSON(500, gin.H{"error": "点赞失败"})
|
|
return
|
|
}
|
|
var count int64
|
|
db.Model(&models.SiteLike{}).Count(&count)
|
|
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
|
|
}
|
|
|
|
func normalizeDanmakuColor(color string) string {
|
|
c := strings.TrimSpace(color)
|
|
if c == "rose" || c == "sage" {
|
|
return "champagne"
|
|
}
|
|
if _, ok := danmakuColorWhitelist[c]; ok {
|
|
return c
|
|
}
|
|
return "champagne"
|
|
}
|
|
|
|
func moderationCacheContent(name, content string) string {
|
|
return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content)
|
|
}
|
|
|
|
func hashModerationContent(content string) string {
|
|
sum := sha256.Sum256([]byte(content))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func saveModerationCache(typ, content, status, reason string) {
|
|
row := models.AiModerationCache{
|
|
Content: content, ContentHash: hashModerationContent(content),
|
|
Type: typ, Status: status, Reason: reason,
|
|
}
|
|
if err := db.Create(&row).Error; err != nil {
|
|
log.Printf("写入审核缓存失败: %v", err)
|
|
}
|
|
}
|
|
|
|
func moderateOrPass(name, content, typ string) (bool, string) {
|
|
guest := strings.TrimSpace(name)
|
|
body := strings.TrimSpace(content)
|
|
if body == "" {
|
|
return true, ""
|
|
}
|
|
cacheKey := moderationCacheContent(guest, body)
|
|
cacheHash := hashModerationContent(cacheKey)
|
|
|
|
var cached models.AiModerationCache
|
|
err := db.Where("type = ? AND content_hash = ?", typ, cacheHash).
|
|
Order("id desc").First(&cached).Error
|
|
if err == nil {
|
|
if cached.Status == "approved" {
|
|
return true, ""
|
|
}
|
|
reason := strings.TrimSpace(cached.Reason)
|
|
if reason == "" {
|
|
reason = "内容未通过审核"
|
|
}
|
|
return false, reason
|
|
}
|
|
if sparkClient == nil || !sparkClient.Enabled() {
|
|
return true, ""
|
|
}
|
|
ok, reason, err := sparkClient.ModerateText(guest, body)
|
|
if err != nil {
|
|
log.Printf("AI 审核失败,放行: %v", err)
|
|
return true, ""
|
|
}
|
|
if !ok {
|
|
if strings.TrimSpace(reason) == "" {
|
|
reason = "内容未通过审核"
|
|
}
|
|
saveModerationCache(typ, cacheKey, "rejected", reason)
|
|
return false, reason
|
|
}
|
|
saveModerationCache(typ, cacheKey, "approved", "")
|
|
return true, ""
|
|
}
|
|
|
|
func defaultUploadConfig() models.UploadConfig {
|
|
return models.UploadConfig{ID: 1, Provider: "local"}
|
|
}
|
|
|
|
func loadUploadConfig() models.UploadConfig {
|
|
var config models.UploadConfig
|
|
if err := db.First(&config, 1).Error; err != nil {
|
|
config = defaultUploadConfig()
|
|
_ = db.Create(&config).Error
|
|
}
|
|
if config.Provider == "" {
|
|
config.Provider = "local"
|
|
}
|
|
return config
|
|
}
|
|
|
|
func uploadConfigResponse(config models.UploadConfig) gin.H {
|
|
return gin.H{
|
|
"provider": config.Provider, "accessKeyId": config.AccessKeyID,
|
|
"hasSecret": config.AccessKeySecret != "", "bucket": config.Bucket,
|
|
"folder": config.Folder, "domain": config.Domain,
|
|
"region": config.Region, "endpoint": config.Endpoint,
|
|
"updated_at": config.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func objectURL(config models.UploadConfig, objectKey string) string {
|
|
escapedKey := utils.EscapedObjectKey(objectKey)
|
|
if strings.TrimSpace(config.Domain) != "" {
|
|
domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/")
|
|
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
|
|
domain = "https://" + domain
|
|
}
|
|
return domain + "/" + escapedKey
|
|
}
|
|
endpoint := strings.TrimRight(utils.NormalizeEndpoint(config.Endpoint, config.Region), "/")
|
|
u, err := url.Parse(endpoint)
|
|
if err == nil && u.Host != "" {
|
|
return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey
|
|
}
|
|
if endpoint == "" {
|
|
return escapedKey
|
|
}
|
|
return endpoint + "/" + escapedKey
|
|
}
|
|
|
|
func canUseOSS(config models.UploadConfig) bool {
|
|
return config.Provider == "oss" &&
|
|
strings.TrimSpace(config.AccessKeyID) != "" &&
|
|
strings.TrimSpace(config.AccessKeySecret) != "" &&
|
|
strings.TrimSpace(config.Bucket) != "" &&
|
|
(strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "")
|
|
}
|
|
|
|
func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
|
|
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), utils.CleanObjectName(file.Filename))
|
|
savePath := filepath.Join("uploads", filename)
|
|
if err := c.SaveUploadedFile(file, savePath); err != nil {
|
|
c.JSON(500, gin.H{"error": "文件保存至服务器失败"})
|
|
return
|
|
}
|
|
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
|
|
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
|
|
}
|
|
|
|
func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config models.UploadConfig) bool {
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "文件打开失败"})
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
|
|
endpoint := utils.NormalizeEndpoint(config.Endpoint, config.Region)
|
|
provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)
|
|
ossConfig := oss.LoadDefaultConfig().
|
|
WithCredentialsProvider(provider).
|
|
WithRegion(config.Region).
|
|
WithEndpoint(endpoint)
|
|
client := oss.NewClient(ossConfig)
|
|
|
|
objectKey := utils.BuildObjectKey(config.Folder, fileHeader.Filename)
|
|
_, err = client.PutObject(context.Background(), &oss.PutObjectRequest{
|
|
Bucket: oss.Ptr(config.Bucket),
|
|
Key: oss.Ptr(objectKey),
|
|
Acl: oss.ObjectACLPublicRead,
|
|
Body: file,
|
|
})
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
|
|
return false
|
|
}
|
|
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
|
|
return true
|
|
}
|
|
|
|
func collectAlbumImages(configData string) []string {
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
|
|
return []string{}
|
|
}
|
|
seen := map[string]bool{}
|
|
images := []string{}
|
|
add := func(value interface{}) {
|
|
src, ok := value.(string)
|
|
if !ok {
|
|
return
|
|
}
|
|
src = strings.TrimSpace(src)
|
|
if src == "" || seen[src] {
|
|
return
|
|
}
|
|
seen[src] = true
|
|
images = append(images, src)
|
|
}
|
|
add(raw["heroImg"])
|
|
add(raw["endImg"])
|
|
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
|
for _, item := range pages {
|
|
page, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
add(page["img1"])
|
|
add(page["img2"])
|
|
if pageImages, ok := page["images"].([]interface{}); ok {
|
|
for _, src := range pageImages {
|
|
add(src)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return images
|
|
}
|